pi-better-show
Interactive blocks for agent-written Markdown: one vocabulary, text fallback, and answer encoding shared by every host, plus the show_doc extension for pi
Package details
Install pi-better-show from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-better-show- Package
pi-better-show- Version
0.4.0- Published
- Nov 8, 2026
- Downloads
- 857/mo · 857/wk
- Author
- weihung1017
- License
- MIT
- Types
- extension
- Size
- 309.5 KB
- Dependencies
- 6 dependencies · 3 peers
Pi manifest JSON
{
"extensions": [
"./src/extension/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-better-show
Interactive blocks for agent-written Markdown. An agent writes fenced blocks (```form, ```table, ...); this package owns what they mean, so every host (a local pi session, a GUI, a chat bridge) shares one block vocabulary, one text fallback for surfaces that cannot draw blocks, and one encoding for the answers users send back.
Status: 0.1.0, core, server, elements, React wrappers, and the show_doc pi extension. Design: docs/specs/2026-11-07-better-show/spec.md.
pi-better-show core: isomorphic, no DOM, no node APIs; depends on `yaml` only
pi-better-show/elements browser: framework-free custom elements, one per block kind (no yaml, no node APIs)
pi-better-show/react thin React wrappers over the elements (React is an optional peer)
pi-better-show/server node: Mermaid to SVG and to Unicode text (beautiful-mermaid)
pi-better-show/extension the pi extension: `show_doc`, a loopback page server, edit mode, event-bus contract v2
The package ships TypeScript source, with no build step. Source uses erasable syntax only, so it loads under node's type stripping and in bun and bundler pipelines.
Blocks
A block is a fenced code block whose first word is the kind, or a GitHub alert. Text fields (body, hint, note, ...) take restricted inline Markdown: bold, italic, code, http(s) links, line breaks. No raw HTML anywhere.
| Kind | Written as | Answer kind |
|---|---|---|
alert |
> [!NOTE|TIP|IMPORTANT|WARNING|CAUTION] title quote |
|
mermaid |
Mermaid source | |
diff |
unified diff | |
cards |
YAML list of {title, tone, body} |
|
options |
YAML list of {title, recommended, body, pros, cons} |
pick |
form |
YAML {title, submit, questions: [{name, label, type, hint, required, other, choices}]} |
form |
review |
YAML {title, submit, pick, columns, items: [{name, label, image, page, height, body}]} |
review |
mindmap |
indented Markdown list; the first item is the root | |
suggest |
YAML list of strings or {label, text} |
suggest |
checklist |
YAML {title, submit, items: [string | {name, label, checked, note}]} |
checklist |
table |
YAML {columns, rows, sort, filter} |
|
steps |
YAML {items: [{title, status: done|doing|todo|blocked, when, body}]} |
|
stats |
YAML list of {label, value, delta, tone, note} |
|
tree |
indented lines (two spaces per level, first line the root): name/ for a folder, then {tags} and # note |
The tree block shows a directory tree for structure reviews:
pi-better-show/
src/ # one package, four entries
core/ {focus} # parser and text, no DOM
parse.ts
validate.ts {new} # checks answers
elements/ # web components
tests/
README.md {changed}
A folder's name ends with /; tags are new, changed, removed, focus, risk (an unknown tag makes the block invalid); the note after # takes inline Markdown; a … N more line stands for entries a limit cut. A tree has at most 2,000 nodes and 32 levels (the root is level 1). The block carries a snapshot, so every host draws it; toText writes it in tree style (├──, └──, tags as [new], notes after —), in a fence on Discord.
pbs-tree prints the fence for a folder: npx pbs-tree [dir] or bunx pbs-tree [dir] (plain JavaScript on node built-ins; runs under node and bun). Output is sorted by name in byte order, so a snapshot is reproducible; symlinks are not followed. Flags follow Linux tree where they exist:
| Flag | Meaning |
|---|---|
-L n |
depth |
-d |
folders only |
-a |
include dot files (default skips them; .git is always skipped) |
-I pattern |
leave out names matching the glob (* ? [..]); | separates several; repeatable |
-P pattern |
list only files matching; a folder is kept when it holds a match (even below the -L cut); ignored with -d |
--gitignore |
honour .gitignore files, those of the folder and of the folders above it up to the repository's top |
--dirsfirst |
folders before files |
--filelimit n |
do not open a folder holding more than n entries; writes … N more in its place |
--prune |
drop folders that hold nothing (what -L hides still counts); ignored with -d |
--changed-since <rev> |
tag new, changed, removed from git diff --name-status <rev> against the working tree; removed files are listed though absent, untracked files are new, a rename is a removed and a new file |
A tree over the block's limits is refused (exit 1) with the node count; narrow it with -L, -I, --gitignore. Names that would read as a note or tags get a no-break space, and a newline in a name becomes ?. Exit codes: 0, 1 for a failure (not a folder, git failed, too large), 2 for a bad flag.
A block whose body is not valid YAML, or does not fit its schema, becomes an invalid segment that keeps its source and the reason; nothing throws.
Core API
import { parse, toText, inline, encodeAnswer } from "pi-better-show";
parse(markdown: string): Segment[]
Splits a document into {type: "markdown", text}, {type: "block", kind, data, index}, and {type: "invalid", kind, source, error} segments, in order. data is typed per kind (BlockDataMap). index counts the document's blocks of every kind from 0; invalid blocks take no number. index is the block's address for answers.
toText(segments: Segment[], profile: TextProfile): string
interface TextProfile {
flavor: "discord" | "line" | "plain"; // Discord Markdown, or plain text
width?: number; // columns a diagram may take; default 60
mermaid?: (source: string, width: number) => string | undefined;
keep?: readonly BlockKind[]; // kinds emitted as their `raw` text instead of converted
lang?: "en" | "zh-TW"; // language of the reply instructions; default "en", anything else is "en"
}
The interactive kinds end with a reply instruction (options, form, review, suggest, and checklist): "Reply with a number.", "Reply in text, one line per question, like 1: b.", and so on. lang: "zh-TW" writes them in Traditional Chinese; pass the conversation's language when you know it. Block content is never translated.
Every BlockSegment and InvalidSegment carries raw: the block's exact original text, fences (or an alert's whole > quote) included, byte for byte. Kinds listed in keep are emitted as raw verbatim, valid or invalid, for a surface that draws them itself; other kinds convert as usual. DISPLAY_KINDS (alert, cards, mermaid, mindmap, diff, table, steps, stats, tree) is the usual keep list for such a surface; INTERACTIVE_KINDS (options, suggest, form, checklist, review) are the kinds a reader answers, the ones a surface that cannot draw them gets as reply instructions. Without keep, output is unchanged.
Renders a document for a surface that cannot draw blocks. A diagram goes through the mermaid hook; when there is no hook, or it returns undefined or a drawing wider than width, the output is the Mermaid source in a code block plus a pointer to the web view. No block-kind fence is left in the output, except Discord's native diff.
inline(text: string): InlineToken[] // restricted inline Markdown, tokenized, never HTML
inlinePlain(text: string): string // the same text without markers
displayWidth(text: string): number // terminal columns: CJK counts 2
reviewItemName(item): string // "B (dark header)"
interface Answer {
kind: "form" | "review" | "feedback" | "pick" | "suggest" | "checklist";
title?: string;
fields: [name: string, value: string][];
ref?: { doc?: string; block: number };
}
encodeAnswer(answer: Answer): string
encodeAnswer produces the text the agent reads. For form, review, and feedback it is byte-compatible with pi-herdr-doc-review's formMessage, reviewMessage, and pageFeedbackMessage given the same fields (the legacy pi-kind field is ignored). ref addresses the block for hosts and does not appear in the text.
| Kind | fields |
Text |
|---|---|---|
form |
one [name, value] per question |
User submitted a form ... then - name: value lines |
review |
title, picked, item/comment pairs, silent |
User reviewed "title": ... |
feedback |
page, quote/comment pairs, overall |
User feedback on the page "page": ... |
pick |
[["picked", option]] |
User picked "option" for "title". |
suggest |
[["text", text]] |
the text itself |
checklist |
one [name, "checked" | "unchecked"] per item |
User updated the checklist "title": then ☑/☐ lines |
validateAnswer(block: BlockSegment, answer: Answer): { ok: true } | { ok: false; reason: string }
validateAnswer is for a host that receives an Answer from an untrusted client. It accepts the answer when its kind fits the block (options takes pick, the others their own kind), every field names a question, choice, or item of that block, and there are at most 64 fields of at most 1,024 characters each. Several values for one name are accepted only for a multi form question (as repeated fields, or the joined value the form element sends); a single or text question, a pick, and a suggestion take one. A required question must be answered (an empty value or (no answer) is not an answer). On a form question without an other box, a value must be one of its choices. A checklist field is an item name once with checked or unchecked; a review may carry picked only when the block has pick. On refusal reason says why, for the host's error reply. Blocks that take no answer (display kinds) are refused.
Several values. A block that gathers several values (a form with a multi question, a checklist, a review) sends nothing on a tick, a pick, or typing: choices stay changeable until the reader presses Send, and a required multi question with none chosen is refused at that question. Only options and suggest send on the click.
Server API
import { mermaidSvg, mermaidText } from "pi-better-show/server";
mermaidSvg(source: string, options?: { scheme?: "light" | "dark" | DiagramColors; font?: string; transparent?: boolean }): string
mermaidText(source: string, width = 60): string | undefined
mermaidSvg draws a self-contained SVG in the colours of scheme (use real colours; an image URL cannot read the host page's CSS). It has no @import and no <script>, and throws on a source it cannot draw. mermaidText draws Unicode box drawing, and returns undefined when the source is invalid, a line is wider than width, or a wide (CJK, emoji) character would misalign the boxes. It is the mermaid hook for toText:
toText(parse(reply), { flavor: "discord", mermaid: mermaidText });
Elements
import "pi-better-show/elements"; // defines every pbs-* tag (a no-op without a DOM)
import { configure } from "pi-better-show/elements";
el.data = segment.data; // or <pbs-form data-json='{...}'>
el.addEventListener("pbs-answer", (event) => send(encodeAnswer(event.detail)));
el.answered = event.detail.fields; // once delivered: locks the element and shows what was sent
A host gives an element the block's parsed data and listens for pbs-answer (bubbles, composed, cancelable) whose detail is a core Answer. An element never reads or writes HTML: text reaches the page as text nodes, and links are http(s) only with rel="noopener noreferrer" and target="_blank". Each element is a shadow root with constructed stylesheets (adoptedStyleSheets), so it runs under default-src 'self': no <style> element, no style attribute. examples/ is such a page; run it with bun run demo (loopback, port 4177).
| Tag | Block | data |
Answer | Parts |
|---|---|---|---|---|
pbs-callout |
alert |
AlertData |
callout header icon title body |
|
pbs-cards |
cards |
CardsData |
grid card title icon body |
|
pbs-options |
options |
OptionsData |
pick |
grid option title badge body pros cons actions pick status |
pbs-form |
form |
FormData |
form |
form title question label hint choices choice badge other textarea error actions submit status sent |
pbs-review |
review |
ReviewData |
review |
review title items item item-title pick media image enlarge frame unavailable body comment actions submit status lightbox close sent |
pbs-mindmap |
mindmap |
MindmapData |
mindmap tree item node toggle label group |
|
pbs-suggest |
suggest |
SuggestData |
suggest |
suggest chips chip status |
pbs-checklist |
checklist |
ChecklistData |
checklist |
checklist title items item progress actions submit status sent |
pbs-table |
table |
TableData |
table bar filter count scroll header head sort body row cell empty |
|
pbs-steps |
steps |
StepsData |
steps step rail marker content title badge when body |
|
pbs-stats |
stats |
StatsData |
grid stat label value delta note |
|
pbs-diff |
diff |
DiffData |
diff scroll line |
|
pbs-diagram |
mermaid |
MermaidData |
diagram image source note |
|
pbs-tree |
tree |
TreeData |
treeview bar expand-all collapse-all tree item node toggle name count tags tag note more group |
TAGS maps a block kind to its tag. There is no whole-document element: the extension renders Markdown on its page server, and other hosts use their own renderer.
Attributes and properties (every element)
| Name | Kind | Meaning |
|---|---|---|
data / data-json |
property / attribute | The block's data; bad JSON or a shape the element cannot draw shows an error panel and fires pbs-error. |
answered / data-answered |
property / attribute | The submitted [name, value][]: locks the element and shows them. undefined reopens it. |
ref, or doc and block attributes |
property / attributes | The block's address, copied into every answer's ref. |
density |
attribute and property | comfortable (default) or compact. |
scheme |
attribute and property | light or dark, to force a scheme; else the element follows the host's color-scheme. |
lang |
attribute (or an ancestor's, or the document's) | zh* shows Traditional Chinese, anything else English. |
refresh() |
method | Draws again. Call it after the host changes color-scheme or the document lang, which an element cannot observe. |
Answers. A pick (pbs-options) or a chip (pbs-suggest) sends at once and locks itself once a listener takes the event; a listener that calls preventDefault() refuses it and the element stays open. A form, review, or checklist stays editable after sending (the status line says "Sent."), so the user can send a changed answer; the host locks it by setting answered. A form's required questions are checked before sending. Ctrl/Cmd+Enter sends from a text box.
Looking after the host: configure
configure({
strings: { "zh-Hant": { send: "送出" } }, // per locale, per word (see Strings)
diagramUrl: (source, scheme) => `/diagram.svg?...`, // an image URL for pbs-diagram; without it the source is shown
imageUrl: (ref) => `/files/${ref}`, // a review item's `image`; default admits http(s) and raster data URLs only
pageUrl: (ref) => `/pages/${ref}`, // a review item's `page`, shown in a sandboxed frame; without it, its name
});
Serve diagram images from pi-better-show/server (mermaidSvg(source, { scheme, transparent: true })); no diagram markup enters the page. resetConfiguration() restores the defaults.
Tokens. Set a --pbs-* property on an element or any ancestor; each has a light-dark() default that follows the host's color-scheme.
| Token | Meaning |
|---|---|
--pbs-bg --pbs-surface |
Panel background; tinted surface (table header, code) |
--pbs-fg --pbs-muted |
Text; secondary text |
--pbs-line --pbs-control |
Panel and divider lines; the edge of an input (3:1 against its surface) |
--pbs-accent --pbs-on-accent |
Buttons, links, selection; text on them |
--pbs-note --pbs-ok --pbs-warn --pbs-risk |
Tones |
--pbs-radius --pbs-gap |
Corner radius; space between blocks and inside them |
--pbs-font --pbs-mono --pbs-font-size |
Type |
--pbs-font-display |
Titles and headings (defaults to --pbs-font) |
Structure carries part names for ::part() restyling. A panel has the same border on all four sides, or none; a tone shows as a tint, a badge, an icon, and the heading colour, never a coloured or thicker one-sided border. examples/demo.css maps the Roundtable and Straw boss tokens as examples.
Keyboard. Every control is a native control, labelled. pbs-mindmap follows the ARIA tree pattern (arrows move, Right and Left open and close, Enter and Space toggle, Home and End jump); pbs-tree follows it too, and opens folders to depth 2 (deeper folders that hold a tagged node open their path) with "expand all" and "collapse all" buttons; pbs-table sorts from header buttons (aria-sort) and filters from a labelled search box.
React
import { PbsBlock, PbsForm } from "pi-better-show/react";
<PbsBlock segment={segment} doc="chat-7" onAnswer={(answer) => send(encodeAnswer(answer))} />
<PbsForm data={formData} answered={fields} density="compact" onAnswer={...} />
One component per element (PbsCallout ... PbsTree) plus PbsBlock, which draws a parse segment with the element for its kind and addresses it by its index. Props: data, answered, onAnswer(answer, event) (return false to refuse), blockRef, density, scheme, lang, className, id, ref. A parent render with equal data does not redraw the element or clear what the user typed. React 19 or later, as an optional peer dependency.
The pi extension: show_doc
pi-better-show is a pi package: it adds show_doc, which shows the agent's documents in your default browser or your editor and carries what you do there back to the agent as messages. It replaces pi-herdr-doc-review (same tool name, no Herdr, no terminal panes).
pi uninstall npm:pi-herdr-doc-review # same tool name: do not load both
pi install npm:pi-better-show
Then /reload in a running session.
Tool. show_doc({paths, reason, view}) returns at once, without waiting for you.
| View | Opens |
|---|---|
page |
The default browser (open, xdg-open), one tab per page, on a loopback page server behind a random token. .md renders with its blocks; .html, .tsx, .jsx run in a sandboxed frame; images show as they are. |
edit (default) |
A text file in your editor: PI_BETTER_SHOW_EDITOR (a command line, for example code -r or subl), else code, else open -t / xdg-open. It must return at once; terminal editors do not work. |
Markdown pages. Prose is Markdown with raw HTML left as text; every block above becomes its element. Select a passage to comment on it; the panel collects comments and an overall note and sends them in one message. A form, pick, chip, checklist, or review reaches the agent as a message: steered into a running turn, or starting a turn when the agent is idle (encodeAnswer). Diagrams are drawn on the server, in the page's colour scheme. Images and links resolve against the page's directory: nothing loads from above it, from a dot file or directory, or from another site. Every response carries a CSP, and the server answers only the loopback host.
Agent pages. .html and .tsx/.jsx run in <iframe sandbox="allow-scripts allow-forms"> without allow-same-origin: no storage, no cookies, and no way to answer on its own: the server refuses a POST whose Origin is not its own (a frame's is null) or that is not JSON, and an event stream not opened from its own page. A <form action="cgi-bin:pi-answer"> or piBetterShow.send(fields) answers through the page around the frame. The page around the frame carries the same feedback panel as a Markdown page: the frame reports the text the user selects to its parent by postMessage({source: "pbs-frame", type: "selection", text}) (cut to 2000 characters, accepted only from the page's own frame with origin null), the user comments on passages and adds an overall note, and one Send delivers a feedback answer named by the HTML <title>, else the file name. A TSX page works the same way. A TSX page's default export is a React component, built by esbuild on each load with the React this package ships; import {PbsBlock, PbsForm, ..., parse, encodeAnswer, send} from "pi-kit" gives it the wrappers.
Edit mode. Your editor opens the file and the extension watches it. When you save, the change since the version the agent last saw goes to it as a zero-context diff (@@ headers carry line numbers): steered into a running turn, or attached to your next message when idle, so a save never starts a turn. A save in the same instant as the agent's own write is not reported.
Live reload and closing. When the agent edits a shown document (write, edit, or bash), open tabs reload. When you close the last tab, the server stops after 30 seconds, and the next show_doc starts a new one and opens its pages again; showing a page whose tab you closed opens it again.
Mini SDD. When the agent writes a docs/specs/<slug>/spec.md whose header says Status: proposed, the folder's decision.md, spec.md, design.md, and verification.md open as pages on their own (only when pi has a UI); a phase file written later joins. PI_BETTER_SHOW_AUTO_OPEN=off stops it.
Event bus, contract version 2. Other viewers (the pi-straw-boss GUI) read the same documents through pi.events; every payload carries version: 2.
| Event | Payload |
|---|---|
better-show:documents |
{version, documents} in full on every change (a document shown, changed by the agent or saved by you, a page closed) and [] when the session ends. Each entry is {id, kind: "blocks" | "html" | "text", display, reason, revision, onScreen, capability?, assets?}. There is no path: display is relative to the working directory, else a ~ path, else the base name. While the page server runs, capability is, for a document on screen or open in the editor, the URL that serves the document (Markdown text for blocks and text; the page as a frame loads it, to be shown in an iframe with sandbox="allow-scripts allow-forms", for html) and assets the directory URL its relative references resolve against. The list keeps the 32 most recent documents. |
better-show:documents-request |
{version} asks for the list again; it is sent once on better-show:documents. Another version, or a request after the session ended, is ignored. |
better-show:answer |
{version, id, answerId, answer} with answer an Answer. It reaches the agent as the same message a page answer produces. |
better-show:answer-result |
{version, id, answerId, accepted, reason?}; reason is closed, invalid (wrong version or shape, or a text document), empty, or a delivery error. A repeated answerId gets its first result back and delivers nothing again. A payload without a string id and answerId gets no reply. |
A viewer that shows an html document in a frame receives its answers as postMessage({source: "pbs-frame", type: "answer", answerId, answer}) from the frame (origin "null") and replies {source: "pbs-host", type: "answer-result", answerId, accepted, reason?}. It also receives {source: "pbs-frame", type: "selection", text, top?, left?} whenever the user's selection in the frame changes (text is empty when it was cleared) and may offer its own feedback panel with it.
Moving from pi-herdr-doc-review. Uninstall it and install this package; show_doc keeps its name and parameters. Gone: Herdr panes, nvim and vim, Chawan and terminal-browser, and the <pi-*> page-kit tags (write form, review, ... blocks instead, or use the pbs-* elements). The event names changed from doc-review:* (version 1) to better-show:* (version 2).
Try it without pi: bun run show starts the page server on examples/ with a fake pi that prints each message the agent would receive.
Development
bun install
bun test
bun run check # tsc, tsc for core without node types, biome
bun run demo # examples/elements.html on http://127.0.0.1:4177/
License
MIT