Phase 0 spike gallery: draws the Markdown, Link and Client cases the doc-editor design measures.

A doc viewer and editor inside the Claude Code terminal UI, built as a Claude Code mod. It renders markdown, HTML, PDF and other doc formats as interactive documents you can read and edit without leaving the session.
The aim is a reading and editing experience close to Notion, Confluence, Google Docs or Word, adapted to a terminal: headings and a table of contents to navigate by, rendered tables and code, links that open, and edits that save back to the file on disk.
The plugin scaffold is in place: the repository is a Claude Code marketplace, the plugin loads and registers /doc, and the test gate runs at commit and at push. The first reader, a Markdown pane with Claude-mediated editing, is in progress; until it lands, /doc answers with one line saying the reader is not built yet. The design is docs/designs/doc-editor-design.md, and the research behind it is in docs/research/.
The work is tracked in this repo's own Beads store (prefix DED).
/plugin install doc-editor --marketplace sergeiwallace/doc-editor
To load it from a clone instead, run claude --plugin-dir plugins/doc-editor. The install note covers requirements, both routes and the test gate.
.claude-plugin/marketplace.json: the marketplace, listing one plugin.plugins/doc-editor/: the plugin. hooks/register.ts holds every call to the mods API, and core/ is the dependency-free document core.scripts/ and test/: the repository's gates and their Vitest suites.packages/: later surfaces, a web app and an MCP App, which will import the same core.See CONTRIBUTING.md to work on it and SECURITY.md to report a vulnerability.
This repository is public and MIT licensed. It holds no secrets, credentials or private data, and nothing of that kind may be committed to it. Copy .env.example to .env for local settings; .env is gitignored.
The scaffold comes from a Copier template that is private, so copier update works only for the maintainer; .copier-answers.yml records the answers it was generated with. Contributors need neither. Git hooks install with bash .githooks/install.sh (needs pre-commit).
hooks/register.ts 100 lines1import type { On } from "claude-code";
2
3import {
4 COMMENTS,
5 LINKS,
6 MARKDOWN_LINKS,
7 SELECTABLE,
8 STRIKE,
9 TASKS,
10 bigText,
11} from "./cases.ts";
12
13const PANE_ID = "gallery";
14const CASES = ["tasks", "strike", "big", "big1", "link", "comments", "keys", "sel"] as const;
15type Case = (typeof CASES)[number];
16
17/**
18 * The Phase 0 gallery. `/gallery <case>` opens one pane drawing that case; `/gallery sel`
19 * also reads `$.ui.selection()` and draws what it returned.
20 */
21export function register(on: On) {
22 let current: Case = "tasks";
23 let selection = "(not read yet)";
24
25 on("session.start", async ($, e, next) => {
26 await $.command.register({
27 name: "gallery",
28 description: `Open the spike gallery: ${CASES.join(", ")}`,
29 });
30 return next(e);
31 });
32
33 on("command.run", { command: "gallery" }, async ($, e) => {
34 const asked = e.args.trim();
35 if (!(CASES as readonly string[]).includes(asked)) {
36 return { text: `gallery: unknown case "${asked}"; one of ${CASES.join(", ")}` };
37 }
38 current = asked as Case;
39 let text: string | undefined;
40 if (current === "sel") {
41 const selected = await $.ui.selection();
42 selection = selected === undefined ? "undefined" : JSON.stringify(selected);
43 text = `gallery sel: isFullscreen=${e.presentation.isFullscreen} selection=${selection}`;
44 }
45 await $.ui.open({ id: PANE_ID, title: `gallery ${current}`, columns: 100, rows: 40 });
46 await $.ui.invalidate("ui.render");
47 return text === undefined ? {} : { text };
48 });
49
50 on("ui.render", { component: "Pane" }, async ($, e, next) => {
51 if (e.requestId !== PANE_ID || e.surface !== "terminal") return next(e);
52 const { Box, Text, Markdown, Link, Client } = await $.ui.resolve(e);
53 const header = Text({
54 key: "header",
55 children: `GALLERY case=${current} fullscreen=${e.viewport?.isFullscreen} placement=${e.props.placement} body=${e.props.bodyColumns}`,
56 });
57 const body = (() => {
58 switch (current) {
59 case "tasks":
60 return [Markdown({ key: "tasks", text: TASKS })];
61 case "strike":
62 return [Markdown({ key: "strike", text: STRIKE })];
63 case "big":
64 return Array.from({ length: 11 }, (_, index) =>
65 Markdown({ key: `big${index + 1}`, text: bigText(index + 1, 100) }),
66 );
67 case "big1":
68 return [Markdown({ key: "big1", text: bigText(1, 1100) })];
69 case "link":
70 return [
71 ...LINKS.map((link) =>
72 Box({
73 key: link.label,
74 flexDirection: "row",
75 children: [
76 Text({ children: `${link.label}: ` }),
77 Link({ href: link.href, label: `${link.label} label` }),
78 ],
79 }),
80 ),
81 Markdown({ key: "mdlinks", text: MARKDOWN_LINKS }),
82 ];
83 case "comments":
84 return COMMENTS.flatMap((comment) => [
85 Text({ key: `${comment.label}-label`, dimColor: true, children: `[${comment.label}]` }),
86 Markdown({ key: comment.label, text: comment.text }),
87 ]);
88 case "keys":
89 return [Client({ key: "keys", module: "./keys.ts", height: 18 })];
90 case "sel":
91 return [
92 Markdown({ key: "selectable", text: SELECTABLE }),
93 Text({ key: "selection", children: `selection: ${selection}` }),
94 ];
95 }
96 })();
97 return Box({ flexDirection: "column", children: [header, ...body] });
98 });
99}
100hooks/cases.ts 77 lines1// The sample documents the gallery draws, one per spike case (phase-0-spike.md).
2
3export const TASKS = [
4 "Task list:",
5 "",
6 "- [ ] open task",
7 "- [x] done task",
8 " - [ ] nested open task",
9 "",
10 "1. [ ] ordered open task",
11 "",
12 "Controls: **bold control** and `code control`.",
13 "",
14 "- plain item control",
15 "* star item control",
16].join("\n");
17
18export const STRIKE = [
19 "Strike: before ~~two tildes~~ after.",
20 "",
21 "Single: before ~one tilde~ after.",
22 "",
23 "Inline <del>del tag</del> and <s>s tag</s>.",
24].join("\n");
25
26/** One element's text: `lines` paragraphs of exactly 98 characters plus a blank line. */
27export const bigText = (element: number, lines: number): string => {
28 const rows: string[] = [];
29 for (let line = 1; line <= lines; line += 1) {
30 const head = `E${String(element).padStart(2, "0")} L${String(line).padStart(3, "0")} `;
31 rows.push(head.padEnd(98, "x"));
32 }
33 return rows.join("\n\n") + "\n\n";
34};
35
36export const COMMENTS: readonly { label: string; text: string }[] = [
37 { label: "C1 inline HTML comment", text: "C1 before <!-- html comment C1 --> after." },
38 {
39 label: "C2 block HTML comment",
40 text: "<!-- block html comment C2 -->\n\nC2 paragraph after the block comment.",
41 },
42 {
43 label: "C3 CriticMarkup comment",
44 text: "C3 before {>>critic comment C3<<} after. {==highlight C3==}{>>note C3<<} end.",
45 },
46 { label: "C4 inline %% comment", text: "C4 before %% obsidian comment C4 %% after." },
47 {
48 label: "C5 block %% comment",
49 text: "%%\nblock obsidian comment C5\n%%\n\nC5 paragraph after the block comment.",
50 },
51 {
52 label: "C6 HTML comment inside a list item",
53 text: "- C6 item one <!-- html comment C6 -->\n- C6 item two",
54 },
55];
56
57export const LINKS: readonly { label: string; href: string }[] = [
58 { label: "L1 https", href: "https://example.com/l1" },
59 { label: "L2 http", href: "http://example.com/l2" },
60 { label: "L3 file", href: "file:///tmp/l3.md" },
61 { label: "L4 mailto", href: "mailto:l4@example.com" },
62 { label: "L5 vscode", href: "vscode://file/tmp/l5.md" },
63 { label: "L6 relative", href: "./l6.md" },
64 { label: "L7 fragment", href: "#l7-heading" },
65 { label: "L8 javascript", href: "javascript:void(0)" },
66 { label: "L9 blank", href: "" },
67];
68
69export const MARKDOWN_LINKS = [
70 "M1 [https](https://example.com/m1) M2 [file](file:///tmp/m2.md)",
71 "M3 [mailto](mailto:m3@example.com) M4 [relative](./m4.md)",
72 "M5 [fragment](#m5-heading) M6 [vscode](vscode://file/tmp/m6.md)",
73].join("\n\n");
74
75export const SELECTABLE =
76 "SELECTME alpha bravo charlie delta echo foxtrot. Select these words with the mouse, then run /gallery sel.";
77hooks/keys.ts 41 lines1import type { ClientKeyEvent, ClientPointerEvent, ClientSurface, RenderElement } from "claude-code";
2
3type KeyLog = { keys: string[]; pointer: string[] };
4
5const describeKey = (event: ClientKeyEvent): string => JSON.stringify(event);
6
7const describePointer = (event: ClientPointerEvent): string =>
8 `${event.type}@${event.x},${event.y}${event.button === undefined ? "" : `:${event.button}`}`;
9
10/**
11 * The key probe: every `onKey` and `onPointer` event the Client receives is written into its
12 * own rendered text, so the screen shows what arrived.
13 */
14export default function KeyProbe(_props: unknown, surface: ClientSurface<KeyLog>): RenderElement {
15 const { Box, Text } = surface.elements;
16 const log = surface.state ?? { keys: [], pointer: [] };
17 if (surface.state === undefined) {
18 surface.onKey((event) => {
19 const now = surface.state ?? log;
20 surface.setState({ ...now, keys: [...now.keys, describeKey(event)].slice(-12) });
21 });
22 surface.onPointer((event) => {
23 if (event.type === "move") return;
24 const now = surface.state ?? log;
25 surface.setState({ ...now, pointer: [...now.pointer, describePointer(event)].slice(-6) });
26 });
27 surface.setState(log);
28 }
29
30 return Box({
31 flexDirection: "column",
32 borderStyle: "single",
33 children: [
34 Text({ children: "KEYPROBE click here, then press Tab, a capital, /" }),
35 Text({ children: `pointer: ${log.pointer.join(" ") || "(none)"}` }),
36 Text({ children: `keys (${log.keys.length}):` }),
37 ...log.keys.map((key, index) => Text({ key: `k${index}`, children: ` ${key}` })),
38 ],
39 });
40}
41