Public: anyone can read this; the crew can change it.

/talk renders code with highlighting and copy, and tables, as it streams, without jumping #8834

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/8816-talk-renders-code-with-highlighting-and → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/8816-talk-renders-code-with-highlighting-and was merged into main as 4c8d359.

Review

Sign in to review this change.
4 files changed +95 −2
docs/features/talk.md +9 −1
@@ -35,8 +35,16 @@ Prior art read: Vercel AI Elements (Apache-2.0 React on shadcn; anatomy and beha
3535 | Many conversations: new, list, search, rename, archive, URL each | next pull | needs a migration: today one row per person |
3636 | Edit and resend, regenerate, Stop, queue | next pulls | need turn deletion and a cancel on `crew/conversation.ts` |
3737 | Attachments, cost and context left, tables, thinking, plan line | later | need storage / token data / markdown tables |
38 | Syntax highlighting while streaming | not adding yet | re-tokenising a half-open fence makes text jump |
38 | Code highlighting and tables in replies | this pull (#8816) | a reply is stored whole and drawn whole, so no half-open fence is ever tokenised |
3939
4040 **Without the script** the page is a plain form: Enter adds a line, starters are links that prefill the box, and Copy and Latest are not drawn (the script draws them, along with the "Enter sends" hint). The log is `role="log"`; with the script, a new message while you are scrolled up is announced in the status line.
4141
4242 **The terminal** (`ssh <forge> talk <words>`, `forge talk`) sends one line and prints the answer; it has no log, so who/when, copy, tool cards, starters and the jump button do not apply to it. The conversation's whole history is read through `GET /api/v1/conversation/turns`, where every turn carries its role and time.
43
44 ## Code, tables and Copy in a reply (#8816)
45
46 An agent reply is Markdown, drawn on the server by the same renderer as a README: fenced code is highlighted, tables and links render, and everything else is escaped (a `<script>` in a reply shows as text). A table wider than the column scrolls inside it; the page does not. The reply sits in the column like a person's turn, with none of the README's padding or line measure.
47
48 A reply arrives as one finished turn, never as growing text, and the page only appends a turn whose id it has not drawn (a repeat is ignored). Turns already on the page are not touched, so nothing above a new reply moves, and the page follows the end only if you were already there (otherwise "Latest" appears). An unfinished fence in a stored reply is closed for drawing. The page's own bytes are held by a test: the stylesheet under 3.5 KB, the script under 6.5 KB, and a page with a code reply under 40 KB.
49
50 **Copy:** each code block and each message has a Copy button, which copies the text, not the markup. The buttons are drawn by the script: with JavaScript off there are none, and the code is still there to select.
src/web/pages/talk.ts +1 −1
@@ -86,7 +86,7 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>): Html {
8686 </li>`;
8787 if (turn.role === "agent")
8888 return html`<li class="talk-turn talk-agent" id="${id}">
89 <div class="talk-body">${renderMarkdown(turn.text)}</div>
89 <div class="talk-body prose">${renderMarkdown(turn.text)}</div>

prose brings 16px 20px padding and a --measure max-width into the chat column. Reset them for .talk-body, or do not use the class.

9090 ${meta("Forge", turn.createdAt)}
9191 </li>`;
9292 const data = parse(turn.text);
src/web/pages/talk_client.ts +5
@@ -11,6 +11,11 @@ export const talkCss = /* css */ `
1111 .talk-turn p { margin: 0 0 8px; }
1212 .talk-turn p:last-child { margin-bottom: 0; }
1313 .talk-turn pre { max-width: 100%; overflow-x: auto; }
14 .talk-body.prose { padding: 0; font-size: inherit; }
15 .talk-body.prose > * { max-width: none; }
16 .talk-body.prose table { display: block; max-width: 100%; overflow-x: auto; }
outdated

Add padding: 0; max-width: none for .talk-body.prose and .talk-body.prose > *, so the turn is not indented against person turns.

17 .talk-body.prose :is(p, ul, ol, pre, table, blockquote) { margin: 0 0 8px; }
18 .talk-body.prose > :last-child { margin-bottom: 0; }
1419 .talk-person { justify-self: end; max-width: 85%; background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 0.5rem 0.75rem; white-space: pre-wrap; }
1520 .talk-agent { justify-self: start; max-width: 100%; padding: 4px 0; }
1621 .talk-tool { color: var(--muted); font-size: var(--fs-min); padding: 0 4px; }
test/talk-page.test.ts +80
@@ -145,6 +145,60 @@ describe("the page", () => {
145145 expect(other).not.toMatch(/<textarea[^>]*>[^<]/);
146146 });
147147
148 test("markdown in a reply: highlighted code, a table and a link render; every <script> is escaped", async () => {
149 const eve = await createUser(app.db, { origin: "seed", trusted: true, username: "eve", email: "[email protected]", password: "a-good-passphrase" });
150 if (!eve.ok) throw new Error(eve.error);
151 promote(app.db, eve.value);
152 say(eve.value, "agent", [
153 "Plain <script>alert(1)</script> and [docs](/docs) and [bad](javascript:alert(1))",
154 "",
155 "```ts",
156 "const s = \"<script>alert(2)</script>\";",
157 "```",
158 "",
159 "| name | note |",
160 "| --- | ---: |",
161 "| a | <script>alert(3)</script> |",
162 ].join("\n"));
163 const page = await (await get("/talk", eve.value)).text();
164 expect(page).not.toContain("<script>alert");
165 expect(page).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
166 expect(page).toContain("&lt;script&gt;alert(2)&lt;/script&gt;");
167 expect(page).toContain("<td class=\"ta-r\">&lt;script&gt;alert(3)&lt;/script&gt;</td>");
168 expect(page).toMatch(/<pre><code class="language-ts">.*tok-keyword/s);
169 expect(page).toContain('<a href="/docs" rel="nofollow noopener">docs</a>');
170 expect(page).not.toContain('href="javascript:');
171 expect(page).toContain("<table>");
172 });
173
174 test("an unfinished code block closes itself and leaves what is above it as it was", async () => {
175 const { turnHtml } = await import("../src/web/pages/talk.ts");
176 const turn = (text: string) => ({ id: "9", role: "agent", text, createdAt: "2026-01-01T00:00:00Z" }) as any;
177 const above = "Intro **bold**\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n";
178 const part = turnHtml(turn(`${above}\`\`\`ts\nconst a = 1;\nconst b`), new Set()).text;
179 const whole = turnHtml(turn(`${above}\`\`\`ts\nconst a = 1;\nconst b = 2;\n\`\`\`\n\nDone.`), new Set()).text;
180 const head = (h: string) => h.slice(0, h.indexOf("<pre>"));
181 expect(head(part)).toBe(head(whole));
182 expect(part.match(/<pre>/g)?.length).toBe(1);
183 expect(part).toContain("</code></pre>");
184 });
185
186 test("an agent turn carries no prose padding or measure into the column", async () => {
187 const { talkCss } = await import("../src/web/pages/talk_client.ts");
188 expect(talkCss).toContain(".talk-body.prose { padding: 0;");
189 expect(talkCss).toContain(".talk-body.prose > * { max-width: none; }");
190 });
191
192 test("the page's own bytes are held: stylesheet and script stay small", async () => {
193 const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts");
194 expect(Buffer.byteLength(talkCss)).toBeLessThan(3_500);
195 expect(Buffer.byteLength(talkJs)).toBeLessThan(6_500);
196 say(alice, "agent", "```ts\nlet x = 1;\n```");
197 const page = await get("/talk", alice);
198 const size = (await page.arrayBuffer()).byteLength;
199 expect(size).toBeLessThan(40_000);
200 });
201
148202 describe("the script, driven against a DOM", () => {
149203 const boot = async (user = "u1", store: Record<string, string> = {}) => {
150204 const sources: Array<{ on: Record<string, (e: { data: string }) => void> }> = [];
@@ -215,6 +269,32 @@ describe("the page", () => {
215269 expect(t.doc.querySelectorAll("#t-2 .talk-meta [data-copy]").length).toBe(1);
216270 });
217271
272 test("a turn that arrives again is not drawn twice or replaced, so what is above it keeps its place", async () => {
273 const t = await boot();
274 const first = t.doc.getElementById("t-1");
275 const copy = first.querySelector("pre [data-copy]");
276 t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><pre><code>x</code></pre></div><p class="talk-meta">Forge</p></li>');
277 const second = t.doc.getElementById("t-2");
278 t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><p>changed</p></div></li>');
279 expect(t.doc.querySelectorAll("#t-2").length).toBe(1);
280 expect(t.doc.getElementById("t-2")).toBe(second);
281 expect(t.doc.getElementById("t-1")).toBe(first);
282 expect(first.querySelector("pre [data-copy]")).toBe(copy);
283 expect(t.doc.querySelectorAll("#t-2 pre [data-copy]").length).toBe(1);
284 });
285
286 test("a turn arriving while scrolled up does not scroll the page", async () => {
287 const t = await boot();
288 let scrolled = 0;
289 t.doc.getElementById("end").scrollIntoView = () => { scrolled++; };
290 Object.defineProperty(t.window, "innerHeight", { value: 800, configurable: true });
291 Object.defineProperty(t.window, "scrollY", { value: 0, configurable: true });
292 Object.defineProperty(t.doc.documentElement, "scrollHeight", { value: 5000, configurable: true });
293 t.window.dispatchEvent(new t.window.Event("scroll"));
294 t.emit("2", '<li class="talk-turn" id="t-2"><div class="talk-body"><pre><code>x</code></pre></div></li>');
295 expect(scrolled).toBe(0);
296 });
297
218298 test("the jump button shows only when scrolled up, and a new message is announced", async () => {
219299 const t = await boot();
220300 const jump = t.doc.querySelector(".talk-jump");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs