Skip to content

feat(plugin): add a session.message.footer slot under timeline messages - #62156

Open
Hona wants to merge 1 commit into
devfrom
message-footer-slot
Open

Hona wants to merge 1 commit into
devfrom
message-footer-slot

Conversation

@Hona

@Hona Hona commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Adds a GUI plugin Slot at session.message.footer: a line under each user message and each assistant response in the session timeline, for per-message details such as cost, tokens, the model, or a link.

  • Place plus input, nothing more. The input is the owning screen, the timeline's session, active (as session.header), and message: { role: "user", info } or { role: "assistant", promptID, messages, completed }. info and messages are the server's own SessionMessageUser / SessionMessageAssistant objects, so a plugin reads tokens, cost, model, content, finish, error and time without a new data model. Every field is a live getter.
  • One footer per response, not per step. A response is every step the model took to answer one prompt, which is what the user sees as one reply. The footer sits under the response's last row and follows it while the response streams; completed flips once the model stops (finished, failed or interrupted).
  • Free while unused. Rows read one shared boolean (footers.enabled()) inside the key memo they already have, and build nothing else: no component, subscription or DOM node. When a plugin fills the slot, the mounted rows mount again with footers; only rows the virtualizer mounts render them; empty content is display: none; height changes reach the row's ResizeObserver. Nothing is reserved.
  • Isolation. Each contribution renders through Contribution: a throwing render records a plugin failure and hides only that contribution.
  • Naming. session.* because the slot lives on the session screen and gets its screen, as packages/gui-plugins/AGENTS.md requires of session.* slots. It pairs with session.message.menu from the message action row PR.
  • No feature flag. The GUI plugin API ships as experimental as a whole (./gui-experimental), and nothing changes until a plugin fills the slot.
  • Rules updated. packages/gui-plugins/AGENTS.md ("Never add work to timeline rows") and the Contribution doc in render.tsx now state this exception exactly. Docs (Slots section with a "Message footers" subsection and reference), README registry row, and full TSDoc (sdk-docs: 0 problems).
ctx.add(Slot, {
  at: "session.message.footer",
  render: (input) => (
    <Show when={input.message.role === "assistant" ? input.message : undefined}>
      {(response) => <span>{response().messages.reduce((sum, step) => sum + (step.cost ?? 0), 0)}</span>}
    </Show>
  ),
})

Sample plugin

A third-party local-folder plugin, opencode-response-receipt, built with the published @opencode/plugin and its @opencode/plugin/gui/vite preset (vite build → dist/gui-experimental.cjs, 4.4 kB). It shows the model, tokens in and out, the cache share, the step count and the cost under each response, with a per-step breakdown on hover, and renders nothing under user messages. The screenshots load its built bundle through the app's own bundle evaluation (pluginDefinition → evaluatePluginBundle), from a temporary, uncommitted builtins entry, because local plugins load only in Desktop. Data comes from the e2e mock server.

Before (slot empty) After (sample plugin)

Hovering a receipt:

Sample layout and src/gui.tsx
session.message.footer/
├── package.json     "exports": { "./gui-experimental": "./dist/gui-experimental.cjs" }
├── vite.config.ts   export default defineConfig({ plugins: [gui()] })
├── tsconfig.json
├── README.md        bun install && bun run build, then list the folder in opencode.jsonc "plugins"
└── src/gui.tsx
import { For, Show, createMemo } from "solid-js"
import { Plugin, Slot, Style, type TimelineResponse } from "@opencode/plugin/gui"

type Step = TimelineResponse["messages"][number]

const css = `
[data-receipt] {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  height: 20px;
  font-family: var(--v2-font-family-sans);
  font-size: 12px;
  font-weight: 440;
  line-height: 18px;
  letter-spacing: 0.05px;
  color: var(--v2-text-text-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  cursor: default;
  user-select: none;
}
[data-receipt][data-live] { opacity: 0.6; }
[data-receipt] [data-part] { overflow: hidden; text-overflow: ellipsis; }
[data-receipt] [data-model] { color: var(--v2-text-text-muted); }
[data-receipt] [data-dot] { opacity: 0.6; }
[data-receipt-steps] {
  display: grid;
  grid-template-columns: auto auto auto auto;
  column-gap: 12px;
  row-gap: 2px;
  font-weight: 440;
  font-variant-numeric: tabular-nums;
  text-align: end;
}
[data-receipt-steps] > :nth-child(4n + 1) { text-align: start; }
[data-receipt-steps] [data-head] { opacity: 0.6; }
`

export default Plugin.define({
  i18n: {
    en: {
      in: "{{value}} in",
      out: "{{value}} out",
      cached: "{{value}} cached",
      "steps.one": "{{count}} step",
      "steps.other": "{{count}} steps",
      step: "Step {{index}}",
      "head.step": "Step",
      "head.in": "In",
      "head.out": "Out",
      "head.cost": "Cost",
    },
  },
  setup(ctx) {
    ctx.add(Style, css)

    const number = createMemo(() => new Intl.NumberFormat(ctx.locale.locale(), { notation: "compact" }))
    const percent = createMemo(() => new Intl.NumberFormat(ctx.locale.locale(), { style: "percent" }))

    const money = createMemo(
      () => new Intl.NumberFormat(ctx.locale.locale(), { style: "currency", currency: "USD", maximumFractionDigits: 4 }),
    )

    // Input counts every token the model read, cached or not, so the cache share is comparable across providers.
    const read = (step: Step) => (step.tokens ? step.tokens.input + step.tokens.cache.read + step.tokens.cache.write : 0)
    const wrote = (step: Step) => (step.tokens ? step.tokens.output + step.tokens.reasoning : 0)

    const Receipt = (props: { response: TimelineResponse }) => {
      const totals = createMemo(() => {
        const steps = props.response.messages
        const input = steps.reduce((sum, step) => sum + read(step), 0)
        const cached = steps.reduce((sum, step) => sum + (step.tokens?.cache.read ?? 0), 0)

        return {
          model: steps.at(-1)?.model.id ?? "",
          input,
          output: steps.reduce((sum, step) => sum + wrote(step), 0),
          cached: input > 0 ? cached / input : 0,
          cost: steps.some((step) => step.cost !== undefined)
            ? steps.reduce((sum, step) => sum + (step.cost ?? 0), 0)
            : undefined,
        }
      })

      const breakdown = () => (
        <div data-receipt-steps>
          <span data-head>{ctx.t("head.step")}</span>
          <span data-head>{ctx.t("head.in")}</span>
          <span data-head>{ctx.t("head.out")}</span>
          <span data-head>{ctx.t("head.cost")}</span>
          <For each={props.response.messages}>
            {(step, index) => (
              <>
                <span>{ctx.t("step", { index: index() + 1 })}</span>
                <span>{number().format(read(step))}</span>
                <span>{number().format(wrote(step))}</span>
                <span>{step.cost === undefined ? "–" : money().format(step.cost)}</span>
              </>
            )}
          </For>
        </div>
      )

      return (
        <Show when={totals().input > 0}>
          <ctx.ui.Tooltip value={breakdown()}>
            <span data-receipt data-live={props.response.completed ? undefined : ""}>
              <span data-part data-model>
                {totals().model}
              </span>
              <span data-dot>·</span>
              <span data-part>{ctx.t("in", { value: number().format(totals().input) })}</span>
              <span data-dot>·</span>
              <span data-part>{ctx.t("out", { value: number().format(totals().output) })}</span>
              <Show when={totals().cached >= 0.01}>
                <span data-dot>·</span>
                <span data-part>{ctx.t("cached", { value: percent().format(totals().cached) })}</span>
              </Show>
              <Show when={props.response.messages.length > 1}>
                <span data-dot>·</span>
                <span data-part>{ctx.plural("steps", props.response.messages.length)}</span>
              </Show>
              <Show when={totals().cost}>
                {(cost) => (
                  <>
                    <span data-dot>·</span>
                    <span data-part>{money().format(cost())}</span>
                  </>
                )}
              </Show>
            </span>
          </ctx.ui.Tooltip>
        </Show>
      )
    }

    ctx.add(Slot, {
      at: "session.message.footer",
      render: (input) => (
        <Show when={input.message.role === "assistant" ? input.message : undefined}>
          {(response) => <Receipt response={response()} />}
        </Show>
      ),
    })
  },
})

Performance

strobe refused this machine (session locked, 56% background CPU load), so this is an alternating A/B instead: the production SessionTimeline inside the real plugin host and PluginLinks, 300 exchanges (600 message rows, all mounted, no virtualizer: the worst case), headless Chromium. Seven rounds in alternating order (dev, PR, PR with one footer contribution), three warm mounts each; time is the synchronous mount plus a forced layout.

Variant Median mount (ms) IQR (ms) DOM nodes Footer elements
dev 370.9 358.7–407.0 13,801 0
PR, no contribution 376.2 357.2–403.2 13,801 0
PR, one contribution 393.0 376.0–423.2 14,701 600
  • Per-round median of PR minus dev: −7.0 ms (range −12.0 to +28.8): no measurable difference, and identical DOM.
  • One contribution adds about 23 ms (6%) across 600 mounted rows, 900 nodes (600 footer wrappers plus the plugin's 300 badges). In the app only the rows on screen mount.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="currentColor">
  <style>
    .f { transform-origin: 24px 0; animation: grow 3s ease-out infinite; }
    .f2 { animation-delay: .5s; }
    @keyframes grow { 0%, 12% { transform: scaleX(0); opacity: 0 } 40%, 100% { transform: scaleX(1); opacity: .45 } }
    @media (prefers-reduced-motion: reduce) { .f { animation: none } }
  </style>
  <rect x="150" y="6" width="66" height="10" rx="5" opacity=".35"/>
  <rect x="24" y="22" width="150" height="5" rx="2.5" opacity=".7"/>
  <rect x="24" y="31" width="112" height="5" rx="2.5" opacity=".7"/>
  <g class="f" opacity=".45">
    <rect x="24" y="42" width="22" height="3" rx="1.5"/>
    <circle cx="51" cy="43.5" r="1"/>
    <rect x="56" y="42" width="16" height="3" rx="1.5"/>
    <circle cx="77" cy="43.5" r="1"/>
    <rect x="82" y="42" width="14" height="3" rx="1.5"/>
    <circle cx="101" cy="43.5" r="1"/>
    <rect x="106" y="42" width="18" height="3" rx="1.5"/>
  </g>
  <rect x="24" y="54" width="132" height="5" rx="2.5" opacity=".7"/>
  <g class="f f2" opacity=".45">
    <rect x="24" y="64" width="22" height="3" rx="1.5"/>
    <circle cx="51" cy="65.5" r="1"/>
    <rect x="56" y="64" width="16" height="3" rx="1.5"/>
    <circle cx="77" cy="65.5" r="1"/>
    <rect x="82" y="64" width="18" height="3" rx="1.5"/>
  </g>
</svg>

Addresses #61385 (per-message plugin UI, alongside the message action row) and #53019 (plugins showing their own per-message details without patching the timeline); replaces the timeline DOM injection community plugins do today.


Added by the opencode bot (AI-generated).

Related: #61385, #53019

Public API: adds the session.message.footer slot and the TimelineUserMessage / TimelineResponse types to the experimental GUI plugin API (backwards compatible)

@Hona
Hona requested a review from Brendonovich as a code owner November 5, 2026 18:26
Copilot AI balanced review requested due to automatic review settings November 5, 2026 18:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot works as described. I ran the new component story and the rest of plugin-host.spec.ts (40/40) in headless Chromium, plus the session-ui unit tests (561 pass). Type checks pass on app, session-ui and plugin. With no plugin, rows build nothing; with one, footers show live input, a throwing contribution fails only its own plugin, and the response footer follows the newest step and flips completed when the response finishes.

Public API: additive to the experimental GUI plugin API. It adds the session.message.footer key to SlotMap, with input { screen, session, active, message }, and exports two new types, TimelineUserMessage and TimelineResponse, from @opencode/plugin/gui. In the app, PluginLinks now requires a screen prop (internal). Nothing existing changes, so it is backwards compatible.

One gap worth fixing: the response footer is anchored on the last model-output row only (details inline).

Related: #61385, #53019

)
}

// The response's footer renders under its last part row, and moves to each new one while the response streams.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The footer only attaches to the last AssistantPart row, because lastAssistantGroupKey only tracks those rows. That leaves two cases the slot docs don't cover:

  • A response that fails or is interrupted before writing any output (only an Error row) gets no footer at all. A plugin can't show that response's error or cost, even though the docs say the slot is under each response and that completed flips on failure.
  • When parts are followed by an Error row, the footer sits above the error card instead of under the response.

Anchoring on the last row of the turn (Error included), or documenting that only responses with output get a footer, would close this.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants