Skip to content

Commit dfddf98

Browse files
committed
feat(tui): session.message.completion slot and a notice row kind for plugins
A plugin can now draw a background subagent's or shell's completion line (the ↳ Subagent finished · … row), and layout rules see system and synthetic messages as "notice" rows, so notices can stack tight.
1 parent a65dc36 commit dfddf98

4 files changed

Lines changed: 73 additions & 19 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ export interface SlotMap {
257257
readonly "session.message.user": SessionMessageInput
258258
/** The agent, model and duration line closing an assistant turn. */
259259
readonly "session.message.assistant.footer": SessionMessageInput
260+
/** A background subagent's or shell's completion notice (`↳ Subagent finished · …`). */
261+
readonly "session.message.completion": SessionMessageInput
260262
readonly "session.part.text": SessionPartInput
261263
readonly "session.part.reasoning": SessionPartInput
262264
/** One tool call; `tool` is the canonical tool name (shell, patch, subagent, read, ...). */
@@ -553,7 +555,8 @@ export interface Keymap {
553555
}
554556

555557
/** The kind of one transcript row, as layout rules see it. */
556-
export type TranscriptRowKind = "user" | "text" | "reasoning" | "tool" | "group" | "footer" | "other"
558+
/** A "notice" is a system or synthetic message: a completion, an instruction load, a goal update. */
559+
export type TranscriptRowKind = "user" | "text" | "reasoning" | "tool" | "group" | "footer" | "notice" | "other"
557560

558561
/**
559562
* Host layout knobs a plugin may set. Every field is optional; an unset field

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

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,7 +1685,11 @@ function transcriptRowKind(
16851685
): TranscriptRowKind {
16861686
if (row.type === "group") return "group"
16871687
if (row.type === "assistant-footer") return "footer"
1688-
if (row.type === "message") return message(row.messageID)?.type === "user" ? "user" : "other"
1688+
if (row.type === "message") {
1689+
const type = message(row.messageID)?.type
1690+
if (type === "user") return "user"
1691+
return type === "system" || type === "synthetic" ? "notice" : "other"
1692+
}
16891693
if (row.type !== "part") return "other"
16901694
const item = message(row.ref.messageID)
16911695
if (item?.type !== "assistant") return "other"
@@ -2193,21 +2197,23 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
21932197
</InlineToolRow>
21942198
}
21952199
>
2196-
<box
2197-
marginLeft={3}
2198-
onMouseOver={() => childID() && setHover(true)}
2199-
onMouseOut={() => setHover(false)}
2200-
onMouseUp={() => {
2201-
if (renderer.getSelection()?.getSelectedText()) return
2202-
const id = childID()
2203-
if (id) navigate({ type: "session", sessionID: id })
2204-
}}
2205-
>
2206-
<text wrapMode="none">
2207-
<span style={{ fg: color() }}>{heading()}</span>
2208-
<span style={{ fg: theme.text.muted }}>{suffix()}</span>
2209-
</text>
2210-
</box>
2200+
<Slot path="session.message.completion" input={{ sessionID: ctx.sessionID, messageID: props.message.id }}>
2201+
<box
2202+
marginLeft={3}
2203+
onMouseOver={() => childID() && setHover(true)}
2204+
onMouseOut={() => setHover(false)}
2205+
onMouseUp={() => {
2206+
if (renderer.getSelection()?.getSelectedText()) return
2207+
const id = childID()
2208+
if (id) navigate({ type: "session", sessionID: id })
2209+
}}
2210+
>
2211+
<text wrapMode="none">
2212+
<span style={{ fg: color() }}>{heading()}</span>
2213+
<span style={{ fg: theme.text.muted }}>{suffix()}</span>
2214+
</text>
2215+
</box>
2216+
</Slot>
22112217
</Show>
22122218
)
22132219
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Plugin } from "@opencode/plugin/tui"
2+
3+
// Claims completion notices and stacks notices tight.
4+
export default Plugin.define({
5+
id: "fixture.completion-notice",
6+
setup(context) {
7+
context.ui.layout({ rowGap: (row) => (row.kind === "notice" && row.previous === "notice" ? 0 : 1) })
8+
context.ui.slot({
9+
replace: "session.message.completion",
10+
render: (input) => <text>Completion claimed: {input.messageID}</text>,
11+
})
12+
},
13+
})

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const goal = {
5555
updatedAt: 0,
5656
}
5757

58-
async function openSession(plugin: string) {
58+
async function openSession(plugin: string, history: ReadonlyArray<unknown> = messages) {
5959
const fixture = await copyPlugin(plugin)
6060
const setup = await createAppFixture({
6161
width: 100,
@@ -65,7 +65,7 @@ async function openSession(plugin: string) {
6565
fetch: (url) => {
6666
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
6767
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
68-
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages, cursor: {} })
68+
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: history, cursor: {} })
6969
if (/^\/api\/session\/[^/]+\/(inbox|permission)$/.test(url.pathname)) return json({ data: [] })
7070
if (url.pathname === "/api/model")
7171
return json({
@@ -205,3 +205,35 @@ test("a plugin picks the bare rail composer", async () => {
205205
const prose = spans.findIndex((line) => line.spans.some((span) => span.text.includes("is fixed")))
206206
expect(rgbToHex(color(rail, "┃"))).toBe(rgbToHex(color(prose, "is fixed")))
207207
})
208+
209+
test("a plugin claims completion notices, and layout rules see notices as their own kind", async () => {
210+
const notice = (id: string, created: number, metadata: Record<string, unknown>, description: string) => ({
211+
type: "synthetic",
212+
id,
213+
text: "Notice",
214+
description,
215+
metadata,
216+
time: { created },
217+
})
218+
// Newest first, as the server pages them.
219+
const history = [
220+
notice("msg_goal", 6, { source: "goal" }, "Goal updated"),
221+
notice("msg_done_b", 5, { source: "subagent", state: "completed", agent: "worker", childID: "ses_b" }, "Fix b"),
222+
notice("msg_done_a", 4, { source: "subagent", state: "completed", agent: "worker", childID: "ses_a" }, "Fix a"),
223+
...messages,
224+
]
225+
await using app = await openSession("completion-notice", history)
226+
await app.ready
227+
const frame = await waitForHighlightedFrame(
228+
app,
229+
(frame) => frame.includes("Completion claimed: msg_done_b") && frame.includes("Goal updated"),
230+
)
231+
const lines = frame.split("\n")
232+
const first = lines.findIndex((line) => line.includes("Completion claimed: msg_done_a"))
233+
// Each completion is the plugin's; notices after notices stack with no blank row between them.
234+
expect(first).toBeGreaterThan(lines.findIndex((line) => line.includes("The parser is fixed.")))
235+
expect(lines[first + 1]).toContain("Completion claimed: msg_done_b")
236+
// Other notices keep the host's own row.
237+
expect(lines[first + 2]).toContain("Goal updated")
238+
expect(frame).not.toContain("Worker finished")
239+
})

0 commit comments

Comments
 (0)