OpenCode
A recreation of an OpenCode terminal session with a transcript, the prompt and the sidebar.
A rebuild of a session in the OpenCode terminal UI, composed from the OpenCode components. It’s a working chat: type a prompt and press Enter, and OpenCode thinks, runs its tools and writes the reply while the status row shows the scanner. Press Escape to interrupt the turn. The transcript sticks to the bottom like a terminal and the sidebar shows from the md breakpoint up.
"use client";
import { Fragment, useEffect, useLayoutEffect, useRef } from "react";
import { OpencodeActivity } from "../components/opencode-activity";
import { OpencodeComposer } from "../components/opencode-composer";
import { OpencodeMessage } from "../components/opencode-message";
import { OpencodeScrollArea } from "../components/opencode-scroll-area";
import { OpencodeSidebar } from "../components/opencode-sidebar";
import { OpencodeTurnFooter } from "../components/opencode-turn-footer";
import { OpencodeWindow } from "../components/opencode-window";
import {
OPENCODE_DEMO_SESSION,
OPENCODE_DEMO_TURNS,
OPENCODE_REPLIES,
} from "../constants/opencode-demo";
import { useOpencodeChat } from "../hooks/use-opencode-chat";
import type {
OpencodeChatActivity,
OpencodeDemoBlock,
OpencodeDemoSpan,
} from "../types/opencode";
const spanKey = (span: OpencodeDemoSpan, index: number) =>
`${index}-${typeof span === "string" ? span : Object.values(span)[0]}`;
const Spans = ({ spans }: { spans: OpencodeDemoSpan[] }) =>
spans.map((span, index) => {
const key = spanKey(span, index);
if (typeof span === "string") {
return <Fragment key={key}>{span}</Fragment>;
}
if ("code" in span) {
return <code key={key}>{span.code}</code>;
}
return <strong key={key}>{span.strong}</strong>;
});
const ReplyBlock = ({ block }: { block: OpencodeDemoBlock }) => {
if ("heading" in block) {
return <h3>{block.heading}</h3>;
}
if ("items" in block) {
return (
<ul>
{block.items.map((item) => (
<li key={item.id}>
<Spans spans={item.spans} />
</li>
))}
</ul>
);
}
return (
<p>
<Spans spans={block.text} />
</p>
);
};
/** How close to the bottom still counts as following the transcript. */
const FOLLOW_THRESHOLD_PX = 48;
/** Consecutive tool lines stack without a gap, thoughts stand alone. */
const groupActivities = (activities: OpencodeChatActivity[]) =>
activities.reduce<OpencodeChatActivity[][]>((groups, activity) => {
const last = groups.at(-1);
const stacks =
last && activity.kind !== "thought" && last[0]?.kind !== "thought";
if (stacks) {
last.push(activity);
} else {
groups.push([activity]);
}
return groups;
}, []);
export default function OpencodeDemo() {
const session = OPENCODE_DEMO_SESSION;
const chat = useOpencodeChat(OPENCODE_DEMO_TURNS, OPENCODE_REPLIES);
const scrollRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const viewportOf = () =>
scrollRef.current?.querySelector<HTMLElement>(
'[data-slot="opencode-scroll-area-viewport"]'
);
// Open on the latest turn like a terminal. Scrolling the viewport directly
// keeps the host page from jumping.
useLayoutEffect(() => {
const viewport = viewportOf();
viewport?.scrollTo({ top: viewport.scrollHeight });
}, []);
// Follow new output only while the reader sits at the bottom, so scrolling
// up to reread a turn is not undone by the next reply block. Sending always
// jumps back down.
useEffect(() => {
const viewport = viewportOf();
if (!viewport) {
return;
}
const handleScroll = () => {
followRef.current =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <
FOLLOW_THRESHOLD_PX;
};
viewport.addEventListener("scroll", handleScroll, { passive: true });
return () => viewport.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
if (!followRef.current) {
return;
}
const viewport = viewportOf();
viewport?.scrollTo({ top: viewport.scrollHeight });
}, [chat.turns]);
const handleSend = (text: string) => {
followRef.current = true;
return chat.send(text);
};
return (
<OpencodeWindow className="h-150">
<div className="grid min-h-0 flex-1 md:grid-cols-[minmax(0,1fr)_minmax(16rem,30%)]">
<div className="flex min-h-0 min-w-0 flex-col gap-[1lh] px-[2ch] pt-[1lh] pb-[0.5lh]">
<OpencodeScrollArea className="min-h-0 flex-1" ref={scrollRef}>
<div className="flex flex-col gap-[1lh] pr-[1.5ch]">
{chat.turns.map((turn) => (
<Fragment key={turn.id}>
<OpencodeMessage from="user">{turn.prompt}</OpencodeMessage>
{groupActivities(turn.activities).map((group) => (
<div key={group[0]?.id}>
{group.map(
({ body, id, running: _running, ...activity }) => (
<OpencodeActivity key={id} {...activity}>
{body}
</OpencodeActivity>
)
)}
</div>
))}
{turn.reply.length > 0 && (
<OpencodeMessage>
{turn.reply.map((block) => (
<ReplyBlock block={block} key={block.id} />
))}
</OpencodeMessage>
)}
{(turn.status === "done" ||
turn.status === "interrupted") && (
<OpencodeTurnFooter
agent={session.agent}
duration={turn.duration}
interrupted={turn.status === "interrupted"}
model={session.model}
/>
)}
</Fragment>
))}
</div>
</OpencodeScrollArea>
<OpencodeComposer
agent={session.agent}
busy={chat.busy}
context={session.context}
cwd={session.cwd}
effort={session.effort}
model={session.model}
onSend={handleSend}
onStop={chat.stop}
placeholder=""
provider={session.provider}
/>
</div>
<OpencodeSidebar
branch={session.branch}
className="hidden md:flex"
cwd={session.cwd}
servers={session.servers}
spent={session.spent}
title={session.title}
tokens={session.tokens}
used={session.used}
version={session.version}
/>
</div>
</OpencodeWindow>
);
}
Installation
$ bunx --bun shadcn@latest add @notra/opencodeThis adds every OpenCode component to components/opencode along with the demo session and the useOpencodeChat hook. The hook starts from the finished turns you pass it and answers each prompt with the next scripted reply from OPENCODE_REPLIES. Pass chat.busy, chat.send and chat.stop to OpencodeComposer as busy, onSend and onStop, and replace the hook with your agent’s stream to run a real session.
The components use the system monospace stack through font-opencode. Point it at your own mono font to change it. Spacing is measured in terminal cells, ch across and lh down, so it follows the font.
Layout
OpencodeWindow is a shadcn Card. The demo splits it with a CSS grid into the transcript column and an OpencodeSidebar. The transcript scrolls in an OpencodeScrollArea, a Base UI scroll area with a square terminal scrollbar, and opens on the latest turn. OpencodeComposer sits under it.
Theming
The block is built on the shadcn Base UI style. It uses shadcn Button, Card, Collapsible, InputGroup and Kbd, and the CLI installs any you’re missing.
Its colors are --opencode-* tokens that the CLI adds to your CSS, mapped to utilities like bg-opencode-panel and text-opencode-blue. They ship with values from OpenCode’s light and dark themes, so the window follows your app’s theme. Change the tokens in your CSS to retheme it:
:root {
--opencode-bg: #090909;
--opencode-blue: var(--primary);
}
Trademarks
OpenCode is a trademark of its respective owner. Notra is not affiliated with or endorsed by the OpenCode project. See Trademarks.