Skip to content

The pi extension

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).

Terminal window
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.

show_doc({paths, reason, view}) returns at once, without waiting for you; whatever you do on the page or in the editor reaches the agent later, as a message.

sequenceDiagram
  participant A as Agent
  participant E as show_doc
  participant S as Page server
  participant B as Browser page
  A->>E: show_doc(paths, reason, "page")
  E->>S: start it once, allow the files
  E->>B: open the URL
  E-->>A: "Opened ... and returned" at once
  Note over B: the reader answers, or<br/>comments in the panel
  B->>S: POST /answer {answerId, answer, page}
  S->>E: shape checked, origin checked
  E->>A: encodeAnswer as a message<br/>(steered in, or it starts a turn)
  A->>E: write or edit the file
  S-->>B: "reload" event
The two views
View Opens
page The default browser (open, xdg-open, on Windows rundll32 url.dll,FileProtocolHandler from %SystemRoot%), 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 / Notepad on Windows (where code counts only as code.exe: VS Code’s code.cmd needs a shell, so name Code.exe by its full path in PI_BETTER_SHOW_EDITOR; backslashes there are literal). It must return at once; terminal editors do not work.

A page whose tab you closed opens again when the agent shows it again. view: "page" with a file that is not Markdown, React, HTML, or an image is refused, and a binary file is refused for either view.

show_doc tells the agent what is wrong with the blocks of a document, so a block that will not parse is fixed in the next turn.

What is checked, and where it is reported

show_doc appends the problems of each Markdown document it opens (checkBlocks, see Core API) after its usual reply, and the write and edit tools of the agent get them in their own result for a .md file that holds a block fence: Block problems in questions.md: then - line 40 form question "o5" choice 1 label (warning): .... Files without block fences are not read by the checker; the YAML parser and Mermaid load on the first file that needs them. Mermaid is checked in the extension with beautiful-mermaid, which validates the diagram type and parses what it draws; it does not catch every syntax slip in a flowchart.

A Markdown page is prose plus blocks: the prose is Markdown with raw HTML left as text, and every block above becomes its element. 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).

The page's limits

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. A POST to /answer must come from the page itself (its Origin) and carry JSON, and the server names the document, so a page cannot answer as another.

Select a passage to comment on it, or click a node, an edge, or a subgraph title in a flowchart or state diagram (from the keyboard: Tab to the diagram, the arrow keys to the element, then Enter or Space; Escape in the panel goes back to it, unless it ends an input method’s composition). The panel collects comments and an overall note and sends them in one message; its drafts survive the reload an agent’s edit causes. It is an ordinary feedback answer: each quote with its comment, then Overall: ….

A diagram element is quoted in the diagram’s own words, so the agent can find it in the source it wrote.

flowchart TD
  svg["Diagram SVG<br/>from /diagram.svg"] -->|"fetchDiagramMap"| map["Targets: node, edge,<br/>group, with their shapes"]
  map -->|"buttons over the image"| press["Click, or Tab, arrows,<br/>Enter or Space"]
  press -->|"pbs-diagram-target"| quote["diagramQuote:<br/>[Diagram 2, type] kind: text"]
  quote --> panel["Feedback panel<br/>one entry per element"]
  panel -->|"Send: quote and comment"| msg["encodeAnswer:<br/>> quote, then the comment"]
How a quote reads

A quote names the diagram by its position among the page’s diagrams and its type, then the element: > [Diagram 2, stateDiagram-v2] edge: Paid → Refunded "money back", > [Diagram 1, flowchart] node: Check, > [Diagram 1, flowchart] group: Review. A node is its label (start and end for a state diagram’s [*]); an edge is <from> → <to>, its ends named as their own quotes name them, with the edge’s label in double quotes when it has one; a group is the subgraph’s title. Mermaid entity codes in a label (#quot;, #35;) are written as the character. The quote is for a reader, not a grammar to parse.

A quote names one element: only when two elements of a diagram would read alike does it add a suffix, a node or group’s id (node: Check (B), group: One (S1)), and ([*]) for a [*] beside a state named start or end (node: start ([*])), which keeps its plain name. An edge’s ends read as their quotes do (edge: Check (A) → Done); only an edge whose ends read alike across kinds, a node and a group with one title, adds their ids (edge: One → B (S → B)), and edges still alike a number in drawing order (edge: A → B "retry" #2). The number follows a run of # that no label or id of the diagram has, so it never reads as part of a label: beside a node labelled B #1, the two edges A → B read edge: A → B ##1 and edge: A → B ##2. When a state diagram has several [*], each inside a composite state names that state as its quote does: node: start (in Active), edge: Busy → end (in Active), and node: start (in Work (W1)) when two states share a title; the outer one stays start. It is an ordinary quote in the feedback answer, so the message has the same shape as one for a selection, and clicking an element reopens its draft instead of adding another. A draft keeps each diagram quote’s diagram by its source, so after the reload an agent’s edit causes, a quote names the diagram’s new position when diagrams were added or removed before it; a diagram whose own source changed keeps the position the quote has. When that quote reads like another draft’s, it adds (from an earlier version of the page) (then #2, #3 if still alike), so two diagram drafts never share a quote, and a new comment on the element now at that position is a draft of its own. Pages that run in a frame (.html, .tsx) comment on selected text only. Only flowcharts and state diagrams have targets; other diagram types stay plain images.

An agent that needs more than blocks writes a page of its own: .html, or .tsx/.jsx. Either runs in <iframe sandbox="allow-scripts allow-forms"> without allow-same-origin: no storage, no cookies, and no way to answer on its own. A <form action="cgi-bin:pi-answer"> or piBetterShow.send(fields) answers through the page around the frame.

How an answer leaves the frame

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. The frame hands its answer to the page around it by postMessage; that page posts it as its own, and the message the agent reads begins Agent page <name> sent:, since a page the agent wrote runs its own script and its sends are not a user’s click.

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. Its 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.

In edit mode your editor opens the file and the extension watches it; the agent reads the net change, not every save.

flowchart TD
  save["You save the file"] --> watch["The extension sees the change<br/>and keeps the version<br/>the agent last saw"]
  watch --> idle{"Agent idle?"}
  idle -->|"yes"| held["You are told once that<br/>edits wait for your next message"]
  held -->|"your next prompt"| start["The run starts with the<br/>net diff of each changed file"]
  idle -->|"no, working"| quiet["No save for 3 seconds"]
  quiet --> steer["Steered into the running turn"]
  idle -->|"no, and the agent is about<br/>to write a file you changed"| first["Your change goes first,<br/>at once"]
What the agent receives, and its limits

Saves are not sent one by one: the agent gets the net change of each file since the version it last saw, as a zero-context diff (@@ headers carry line numbers), one message with a section per file. When idle, it is attached to your next message; while the agent works, it is steered in once you stop saving for 3 seconds; before the agent writes a file you changed, your change goes first. Intermediate saves never reach the agent, a change undone before the report sends nothing, and a diff longer than 200 lines is cut with a count of the lines left out. A save in the same instant as the agent’s own write is not reported.

The message reads User edited notes.md: and then the diff in a diff fence. When a shell command ran while you saved, it reads notes.md changed while a shell command ran; this may include the command's own changes, not only the user's edits: instead.

Open tabs follow the file: when the agent edits a shown document (write, edit, or bash), they 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.

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.

Other viewers (the pi-straw-boss GUI) read the same documents through pi.events, so the contract is versioned: every payload carries version: 2.

The four events
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.

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.