Skip to content

feat(vscode): render Markdown in the chat and enrich the status row - #124

Merged
zy1233 merged 4 commits into
mainfrom
feat/vscode-markdown-render
Nov 13, 2026
Merged

zy1233 merged 4 commits into
mainfrom
feat/vscode-markdown-render

Conversation

@zy1233

@zy1233 zy1233 commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two changes to the chat of the VS Code extension. Three commits:

Commit Change
084001e Markdown is drawn as Markdown (markdown-it as a parser only)
4376f93 A streamed answer is drawn at most once each 80 ms (perf; can be dropped alone)
48a6e09 The status row shows context use, turn time and folder (added in this update)

1. Markdown in the chat (commits 1 and 2)

The chat showed headings, tables and bold text as raw marks (##, |---|, **). The reader of 1.0.0 made only paragraphs, flat lists, code and links. Now markdown-it is a parser only: the webview still builds DOM nodes from plain data, and it never sets innerHTML (design rule 5).

agentText (host -> webview)
  setAgentText
    throttle: 80 ms, first piece at once, last piece always      (commit 2)
    drawText
      parseMarkdown(text)                    core/markdown.ts
        markdown-it + markdown-it-cjk-friendly: tokens only, html: false
        tokens -> Block[] / Inline[]         plain data, no HTML string
      renderBlocks(blocks)                   webview/dom.ts
        el() + textContent                   DOM nodes
 packages/pi-vscode/
   src/core/
-    markdown.ts          hand-written reader (paragraph, flat list, code, link)
+    markdown.ts          markdown-it tokens -> plain data
+    throttle.ts          draw throttle (commit 2)
   src/webview/
     dom.ts               Block[] -> DOM; now also headings, quote, rule, table, em, strong, del
     main.ts              (commit 2) draws a streamed answer at most each 80 ms
   media/chat.css         h1-h6, blockquote, hr, table
   scripts/notices.mjs    a bundled package inside another one (entities) is listed now

What does not change (rule 5, each one has a test):

  • Raw HTML stays text (html: false).
  • An image is never loaded. It shows as a link.
  • A link opens through the host: http, https and mailto only. Another address shows as plain text with its address.
  • Bare main.py, www.x.com and e-mail words are not linked. Only http(s):// and mailto: are.

2. Status row (commit 3)

The header of the chat and the VS Code status bar item show more. The rule: show only a number that someone measured (the agent, or the clock of the host). The extension counts no tokens and has no setting for the size of the window.

agent --usage_update {used, size, cost?}--> ChatController            core/chat-controller.ts
                                              readUsageUpdate()       core/status-row.ts  (finite numbers only)
send() / finishTurn() -- host clock ------> turn { elapsedMs | lastMs }
                                              stateMessage { usage?, turn?, cwd, ... }
  webview   renderHeader()                                            webview/main.ts
              ● zycode  Working · 1 min 12 s                           (the page ticks one element each 500 ms)
              agent 0.9.1 · deepseek-flash · folder pi-python          (full path in the tooltip)
              Context [bar] 42% · 54.2k of 128k tokens · $0.12         (only after a usage_update)
  status bar  StatusBar                                               ui/status-bar.ts
              zycode: Working · context 42%                            (tooltip: numbers, cost, last turn, folder)
Item Source With the bundled agent 0.9.1
Turn time (a clock, then last turn 8.4 s) clock of the host shows
Folder (name in the meta row, full path as tooltip) cwd of the chat shows
Context (bar, percent, tokens, cost) ACP usage_update does not show: 0.9.1 sends no usage_update

The bar turns to a warning colour from 80 % and to a danger colour from 90 %. A value over 100 % shows its real percent, and the bar stops at full.

 packages/pi-vscode/
   src/core/
+    status-row.ts        pure helpers: readUsageUpdate, describeContext, formatTokens/Cost/Duration
     chat-controller.ts   usage_update -> state.usage; host clock -> state.turn
     protocol.ts          StateMessage { usage?, turn? }
   src/webview/main.ts    clock, Context row (meter), folder in the meta row
   src/ui/status-bar.ts   `· context NN%` and the tooltip lines
   media/chat.css         .clock, .usage (meter, warn, danger); the title row wraps in a narrow view
+  test/status-row.test.ts, test/usage.test.ts
 docs/specs/
+  2026-11-13-agent-context-usage.md      investigation record (no agent change)
   2026-11-04-vscode-extension-design.md  section 7d, decisions D36 and D37

The agent is not changed. The request was to record the result only. docs/specs/2026-11-13-agent-context-usage.md has it: the agent has no usage_update; the window size is a 128,000 placeholder for every model (Model.context_window, no config key); Usage.cost stays 0; the harness already counts the tokens (estimate_context_tokens). The record also has a proposal (four options for the window size, a hook on turn_end, the release path) and the open decisions.

Evidence

1. Markdown

Before: the test of 1.0.0 said that these stay text.

parseMarkdown("# title\n**bold** <script>alert(1)</script>")
  -> one paragraph, text "# title\n**bold** <script>alert(1)</script>"

After: the same kind of input in headless Chrome (renders headings, emphasis, lists, a quote, a rule and a table as elements, with no raw marks left). The HTML part still stays text.

"## Title"            -> <h2>Title</h2>
"**b** ~~c~~"         -> <strong>b</strong> <del>c</del>
"| a | b |\n|:-|-:|"  -> div.table-wrap > table  (th.align-left, th.align-right)
"**重要:**文本"       -> <strong>重要:</strong>文本

The last line needs the plugin: plain markdown-it leaves ** raw in 3 of 10 common Chinese patterns that we tried.

How many answers need this? In 47 session files of the author (131 answer texts, a small sample), 41 answers (31 %) had a heading, a table, bold text, a quote, a rule or a nested list.

1.0.0 commits 1 and 2
dist/webview.js (minified) 11.1 KB 118.0 KB
dist/extension.js - no Markdown code in it (0 inputs in the esbuild metafile)
one draw, text 3,000 characters long 5 ms 7 ms
one draw, text 8,000 characters long 12 ms 16 ms
one draw, text 20,000 characters long 26 ms 37 ms (the reason for commit 2)

How I measured a draw: headless Chrome, both readers built in the same way (minified). The text is already N characters long. I add a piece of 4 characters and time the draw. The number is the median of 25 pieces, and the table shows the mean of two runs. The new reader is about 1.4 times slower for each draw. The reader of 1.0.0 also needs more than 20 ms at 20,000 characters. If a model sends 50 pieces each second (an assumption: I did not measure a real model), there are 20 ms between two pieces. Commit 2 limits the draws to 12 each second.

Other checks of commits 1 and 2:

  • The Chrome tests also pass on the minified production bundle (run with a temporary config without the global dev build).
  • Mutation check: without entry.draw.cancel() in resetTranscript, the test does not draw an old answer after the chat was reset fails.
  • Hostile input: 12 slow or deep strings parse in less than 80 ms each. 3000 random mixes of marks (fixed seed) always give a valid shape (a table row has as many cells as its head).

2. Status row

Before (1.0.0 header, and this branch before commit 3): the state and the meta line only.

● zycode  Ready
agent 0.9.1 · deepseek-flash

After, with the bundled agent 0.9.1 (no usage_update):

● zycode  Ready · last turn 8.4 s
agent 0.9.1 · deepseek-flash · folder pi-python

After, with an agent that sends usage_update (a test message; see "Not verified"):

● zycode  Working · 1 min 12 s
agent 0.9.1 · deepseek-flash · folder pi-python
Context [####------] 42% · 54.2k of 128k tokens · $0.12

I looked at both in headless Chrome at a 380 px wide view (an iframe, because headless Chrome has a minimum window width). The longest case (the Always approve label, a clock and a danger bar at 96 %) fits: only the label moves to its own line, and nothing is cut off.

The tests (headless Chrome, status row; unit; controller with a fake agent):

state({ usage: { used: 54200, size: 128000, cost: { amount: 0.12, currency: "USD" } } })
  -> row "usage ok", text "42% · 54.2k of 128k tokens", cost "$0.12"
     meter min 0, max 128000, value 54200, aria-label "Context used"
usage of 79k, 80k, 89k, 90k, 112.5k (of 100k), and no size
  -> ok, warn, warn, danger, danger, plain
     over full: "113% · 113k of 100k tokens", bar value 100000
cost currency "<img src=x onerror=alert(1)>", cwd "/home/u/<b>x</b>"
  -> 0 <img>, 0 <b>                                  (text, never HTML)
busy, turn { elapsedMs: 5000 }, 2.2 s later
  -> "5 s", then "7 s"; the same <select> element     (an open menu stays open)
turn ends with { lastMs: 8432 } -> "last turn 8.4 s"; next state without turn -> no clock
controller: a usage_update with no `used` -> the SDK drops it, the numbers stay, the turn ends
controller: new chat, restart and stop clear the usage and the clocks; a clock that goes back gives 0

Tests at the head (all three commits)

  • Windows, Node 24: npm run check = typecheck + eslint + 333 tests passed (255 before commit 3; +62 status-row, +11 usage, +5 Chrome). 18 are skipped (16 opt-in tests of the real agent, 2 for another OS).
  • npm run notices (production build) passes: dist/webview.js 120.8 KB, dist/extension.js 216.0 KB. The list of third-party packages did not change.
  • CI at 48a6e09 (Linux, Node 22.23.4): all checks pass. 335 tests passed and 16 skipped. The webview file ran its 26 tests in 24.6 s, with no unhandled error. The VSIX job built dist/webview.js at 120.77 KB. The smoke test started the agent from the VSIX. Ruff and pytest (3.11, 3.12, 3.13) pass; no Python file changed.

Not verified

  • A real VS Code window and real themes. I used a headless Chrome page with the theme variables mocked. The status bar item (ui/status-bar.ts) needs a VS Code host, so no test draws it; its two helpers (contextBadge, statusRowLines) are tested.
  • A real usage_update from a real agent. No agent sends one today. I used a fake agent and test messages. The shape is the one of ACP v1 (used and size required, cost optional); the SDK checks it with its own schema.
  • The Context row with true numbers. It needs the agent change in the record. The size of the window is the open question there.

Merge Danger

Door: two-way. A revert brings back the old reader and the old header. There is no change of the wire, of the agent, of the pins (agent-assets.json) or of stored data. The extension only reads a standard message that an agent may send.

Blast Radius: the look of every answer in the chat, and the header (a clock and a folder in the meta row; the title row can wrap in a narrow view). The webview bundle grows by about 110 KB (11.1 KB in 1.0.0, now 120.8 KB). THIRD-PARTY-NOTICES lists 8 more packages (MIT, and BSD-2-Clause for entities). Users get it with the next VSIX release. This PR does not make one: the version stays 1.0.0 and the CHANGELOG has an Unreleased entry.

Note for the user of the bundled agent: the Context row stays hidden until an agent release sends usage_update. Then the extension shows it with no change.

sim added 2 commits November 12, 2026 18:04
The chat showed the marks of headings, tables and bold text as plain text
(##, |---|, **). The old reader in src/core/markdown.ts made only
paragraphs, flat lists, code and links.

markdown-it 15.0.3 and markdown-it-cjk-friendly 3.0.0 now read the text and
give tokens. core/markdown.ts changes the tokens to plain data, and
webview/dom.ts makes DOM nodes from that data. No HTML string is made
(design rule 5): the HTML option is off, an image is not loaded, and a link
that the host would refuse shows as text with its address.

- headings, bold, italic, strikethrough, nested lists, quotes, rules and
  tables (column alignment, a scroll bar for a wide table)
- bold next to Chinese, Japanese and Korean characters (the plugin; plain
  CommonMark leaves the marks raw in 3 of 10 common patterns)
- notices: a bundled package that sits inside another one (entities, inside
  markdown-it) was missed. The last node_modules/ in a path now names the
  package.
- tests: 29 reader tests (hostile texts and 3000 random mixes), 3 Chrome
  tests, 3 tests for the notices
- docs: design rule 5 and D34, README, CHANGELOG

The webview bundle grows from 11 KB to 118 KB.
A draw makes the DOM of the whole answer again, so its cost grows with the
length of the answer. In headless Chrome, with the text already N characters
long, one draw took 7 ms at 3,000 characters, 16 ms at 8,000 and 37 ms at
20,000 (the reader of 1.0.0: 5, 12 and 26 ms). The host sends each piece of
text at once, so a fast model can send more pieces than the page can draw.

The first piece is drawn at once. The pieces that come in a pause join into
one draw at the end of the pause (counted from the end of the last draw).
The last piece is always drawn. A reset of the chat cancels a planned draw,
and a draw is skipped when the text did not change (the thinking grew).

- src/core/throttle.ts: a small leading and trailing throttle with a
  scheduler that a test can drive (7 tests, a fake clock)
- 3 Chrome tests: join of the pieces, cancel at reset, no new draw when only
  the thinking grows
- docs: design D35, CHANGELOG

This commit is separate, so that it can be dropped if 80 ms is not wanted.
@zy1233
zy1233 force-pushed the feat/vscode-markdown-render branch from ce2ee9f to 4376f93 Compare November 12, 2026 10:22
The status row of the chat, and the VS Code status bar item, now show more:

- Context: a bar, the percent, the tokens and the cost, from the standard
  ACP message usage_update. The bar turns to a warning colour from 80 % and
  to a danger colour from 90 %. The status bar shows `context NN%`, and its
  tooltip has the numbers.
- Turn time: a clock while the agent works (the host measures it; the page
  ticks one element each 500 ms), then `last turn 8.4 s`.
- Folder: the name of the folder in the meta row, with the full path as the
  tooltip.

Rule: the extension shows only numbers that someone measured (the agent, or
the host clock). It does not count tokens, and it has no setting for the
size of the window.

Agent 0.9.1 (bundled) sends no usage_update, so the Context row does not
show with it yet. The agent is not changed in this commit.
docs/specs/2026-11-13-agent-context-usage.md records what an agent change
needs (window size, a turn_end hook, release path).

Design: section 7d, decisions D36 and D37, of
docs/specs/2026-11-04-vscode-extension-design.md.

Tests: 73 unit and controller tests (status-row, usage), and 5 headless
Chrome tests of the view (text only and never HTML; levels; clock).
@zy1233 zy1233 changed the title feat(vscode): render Markdown in the chat with markdown-it feat(vscode): render Markdown in the chat and enrich the status row Nov 13, 2026
…I for extension-only changes (#126)

The extension moves from packages/pi-vscode/ to vscode/. ci.yml skips Ruff and pytest for a change that only touches vscode/ (without .py files), docs/ or vscode-extension.yml.
@zy1233
zy1233 merged commit 0d375c4 into main Nov 13, 2026
7 checks passed
@zy1233
zy1233 deleted the feat/vscode-markdown-render branch November 13, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant