@gavin-hu/my-pi-agent 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +7 -7
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +51 -9
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +385 -19
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +33 -0
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +1 -1
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -5,7 +5,7 @@
5
5
  * every render (`entries()` refreshes from disk first) and asks for a repaint
6
6
  * after an action settles, so an external edit or a mutation is reflected
7
7
  * without the component owning any authoritative state. Navigation and scroll
8
- * math come from `lib/list-cursor.ts` and the screen chrome from `lib/tui.ts`;
8
+ * math come from `lib/tui/cursor.ts` and the screen chrome from `lib/tui/chrome.ts`;
9
9
  * the inline note editor is the shared `Editor` from `@earendil-works/pi-tui`.
10
10
  */
11
11
 
@@ -14,23 +14,18 @@ import {
14
14
  Editor,
15
15
  Key,
16
16
  matchesKey,
17
- truncateToWidth,
18
17
  type Component,
19
18
  type TUI,
20
19
  type TuiMouseEvent,
21
20
  type TuiMouseEventResult,
22
21
  } from "@earendil-works/pi-tui";
23
22
  import { sanitizeLine } from "../../lib/format.ts";
24
- import {
25
- fitRows,
26
- formatRange,
27
- isCloseKey,
28
- ListCursor,
29
- navigateList,
30
- scrollList,
31
- selectionMarker,
32
- } from "../../lib/list-cursor.ts";
33
- import { dockListFrame, editorTheme, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
23
+ import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
24
+ import { dockListFrame, editorTheme, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
25
+ import { clip } from "../../lib/tui/columns.ts";
26
+ import { emptyState, listRow } from "../../lib/tui/rows.ts";
27
+ import { titleRule } from "../../lib/tui/rules.ts";
28
+ import { paint } from "../../lib/tui/tokens.ts";
34
29
  import { messageOf } from "./errors.ts";
35
30
  import { scopeLabel, type MemoryEntries } from "./format.ts";
36
31
  import type { MemoryScope } from "./types.ts";
@@ -301,6 +296,13 @@ export class MemoryPickerComponent implements Component {
301
296
  this.requestRender();
302
297
  return;
303
298
  }
299
+ // The hint advertises `g`/`G`, so bind them to the ends.
300
+ if (data === "g" || data === "G") {
301
+ this.cursor.set(data === "g" ? 0 : notes.length - 1, notes.length);
302
+ this.selectedKey = notes[this.cursor.selected] ? noteKey(notes[this.cursor.selected]) : undefined;
303
+ this.requestRender();
304
+ return;
305
+ }
304
306
 
305
307
  const focused = notes[this.cursor.selected];
306
308
  if (matchesKey(data, Key.tab) || data === "s") this.cycleFilter();
@@ -344,22 +346,21 @@ export class MemoryPickerComponent implements Component {
344
346
  private summary(width: number): string {
345
347
  const entries = this.callbacks.entries();
346
348
  const filter = this.effectiveFilter();
347
- let text = this.theme.fg("accent", filter === "all" ? "All" : scopeLabel(filter));
348
- text += this.theme.fg("muted", ` · ${entries.project.length} project, ${entries.global.length} global`);
349
- if (!this.callbacks.trusted()) text += this.theme.fg("warning", " · project untrusted");
350
- return truncateToWidth(` ${text}`, width, "…");
349
+ let text = paint(this.theme, "accent", filter === "all" ? "All" : scopeLabel(filter));
350
+ text += paint(this.theme, "meta", ` · ${entries.project.length} project, ${entries.global.length} global`);
351
+ if (!this.callbacks.trusted()) text += paint(this.theme, "warn", " · project untrusted");
352
+ return clip(` ${text}`, width);
351
353
  }
352
354
 
353
355
  /** One note row: selection marker, scope tag, and the sanitized note text. */
354
356
  private noteRow(note: Note, selected: boolean, width: number): string {
355
- const scope = this.theme.fg("muted", scopeLabel(note.scope));
356
- const text = sanitizeLine(note.text);
357
- const label = selected ? this.theme.fg("accent", text) : this.theme.fg("text", text);
358
- return truncateToWidth(
359
- `${selectionMarker(this.theme, selected)}${scope} ${this.theme.fg("dim", "·")} ${label}`,
360
- width,
361
- "…",
362
- );
357
+ const tag = `${paint(this.theme, "meta", scopeLabel(note.scope))} ${paint(this.theme, "faint", "·")}`;
358
+ return listRow(this.theme, width, {
359
+ selected,
360
+ leading: tag,
361
+ label: sanitizeLine(note.text),
362
+ labelRole: selected ? "accent" : "label",
363
+ });
363
364
  }
364
365
 
365
366
  private confirmPrompt(): string {
@@ -377,13 +378,10 @@ export class MemoryPickerComponent implements Component {
377
378
  const body: string[] = [];
378
379
  for (const line of this.editor.render(Math.max(1, width - 2))) body.push(`${pad}${line}`);
379
380
  return dockListFrame({
380
- theme: this.theme,
381
- width,
382
- title: `Memory · ${verb} ${scope}`,
383
- summary: truncateToWidth(
384
- ` ${this.theme.fg("muted", this.mode === "edit" ? "Editing the focused note" : `New ${scope} note`)}`,
381
+ header: titleRule(this.theme, width, `Memory · ${verb} ${scope}`),
382
+ summary: clip(
383
+ ` ${paint(this.theme, "meta", this.mode === "edit" ? "Editing the focused note" : `New ${scope} note`)}`,
385
384
  width,
386
- "…",
387
385
  ),
388
386
  body,
389
387
  hint: screenHint(this.theme, width, ["Enter save", "Esc cancel"]),
@@ -401,7 +399,7 @@ export class MemoryPickerComponent implements Component {
401
399
 
402
400
  const body: string[] = [];
403
401
  if (notes.length === 0) {
404
- body.push(truncateToWidth(` ${this.theme.fg("dim", "No notes yet. Press a to add one.")}`, w));
402
+ body.push(...emptyState(this.theme, w, { label: "No notes yet. Press a to add one." }));
405
403
  } else {
406
404
  const { start, end } = this.cursor.window(notes.length);
407
405
  for (let index = start; index < end; index++) {
@@ -410,15 +408,13 @@ export class MemoryPickerComponent implements Component {
410
408
  }
411
409
 
412
410
  const hint = this.confirm
413
- ? truncateToWidth(` ${this.theme.fg("warning", this.confirmPrompt())}`, w)
411
+ ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
414
412
  : this.notice !== undefined
415
- ? truncateToWidth(` ${this.theme.fg("muted", this.notice)}`, w)
413
+ ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
416
414
  : screenHint(this.theme, w, ["Esc close", "Enter/e edit", "a add", "d forget", "s/Tab scope", "↑/↓", "g/G"]);
417
415
 
418
416
  return dockListFrame({
419
- theme: this.theme,
420
- width: w,
421
- title: "Memory",
417
+ header: titleRule(this.theme, w, "Memory"),
422
418
  summary: this.summary(w),
423
419
  body,
424
420
  range: this.cursor.clipped(notes.length)
@@ -48,8 +48,11 @@ pi --plan # start in plan mode
48
48
  - **Reviewable plan.** `exit_plan_mode` reads the plan file back and presents
49
49
  it for approval. The user approves, keeps planning, or asks for a refinement.
50
50
  - **Browser review on demand.** Press `w` on the review screen (or run
51
- `/plan review [path]`) to open the plan in a local browser, select text to
52
- leave inline comments, and save them back into the plan file itself as
51
+ `/plan review [path]`) to open the plan in a local browser: a reading-first
52
+ page with the plan's H1 as its title, GitHub's Markdown content column, a
53
+ comment rail in
54
+ the left margin, and live section and step progress. Select text to leave an
55
+ inline comment, and save them back into the plan file itself as
53
56
  `> [!REVIEW]` lines. Saved comments return to the model as a refinement
54
57
  request.
55
58
  - **The file is the source of truth.** On approval the model follows the plan
@@ -107,9 +110,9 @@ The review screen is TUI-only. `exit_plan_mode` opens `PlanViewComponent`: a
107
110
  scrollable, width-safe view of the plan with approve/refine/keep in the footer.
108
111
  The body is rendered with Pi's own `Markdown` component and `getMarkdownTheme()`,
109
112
  so headings, lists, inline code, code blocks, links, and quotes carry the active
110
- theme's colors; task items get a `success` `[✓]` (done) or `dim` `[ ]` (pending)
111
- marker. The footer colors the primary actions (`a approve & run` in `success`,
112
- `r refine` in `accent`).
113
+ theme's colors; task items get the shared `◼` (done) or `◻` (pending) marker,
114
+ like the bytefly screen and the question dialog. The footer colors the primary
115
+ actions (`a approve & run` in `success`, `r refine` in `accent`).
113
116
  `a` ends plan mode, restores write access, and seeds `todo`; `r` keeps the plan
114
117
  mounted and opens an inline editor beneath it (`Enter` submits, an empty buffer
115
118
  does nothing, `Esc` returns to the plan); `Esc` keeps planning and tells the
@@ -118,7 +121,8 @@ model the plan was not approved. Scroll with `↑`/`↓` or `j`/`k`, `space`/`b`
118
121
  the next or previous heading; the scroll row names the current section when the
119
122
  plan has more than one. When the plan overflows,
120
123
  a gauge row above the hints shows the scroll position, the percent, and the
121
- visible line range (the range drops first on a narrow terminal); the gauge stays
124
+ visible line range (the range drops first on a narrow terminal); the gauge comes
125
+ from the design system's `gaugeBar` and stays
122
126
  visible while the refine editor is open, since the body above is still clipped.
123
127
  Hints are
124
128
  dropped whole as the terminal narrows, and each footer leads with its safe exit
@@ -189,8 +193,8 @@ command. The read-only guarantee is layered:
189
193
  strips control characters from the plan body before rendering it in the
190
194
  transcript and in non-TUI notices; a plan file cannot drive the terminal.
191
195
  - **Loopback browser review.** The review server binds `127.0.0.1` and requires a
192
- random per-session token in the URL, checks the `Host` header, serves only
193
- `GET`/`POST`, and caps the request body; the page renders plan and comment text
196
+ random per-session token in the URL, rejects a missing or foreign `Host`
197
+ header, serves only `GET`/`POST`, and caps the request body; the page renders plan and comment text
194
198
  with DOM text nodes, never `innerHTML`, so a plan cannot script the reviewer.
195
199
 
196
200
  ## Read-only git shell
@@ -250,26 +254,42 @@ agent file that overrides it.
250
254
 
251
255
  ## Browser review
252
256
 
253
- <img src="../../assets/plan-review.png" alt="The browser-based plan review, showing the plan's sections, a rendered code block, and an inline comment box" width="100%">
257
+ <img src="../../assets/plan-review.png" alt="The browser-based plan review: a sticky header with the plan title and Save/Close, a side panel with step progress and a section outline, and the plan as one measured column with a comment rail and an inline review thread" width="100%">
254
258
 
255
259
  Press `w` on the review screen — or run `/plan review [path]` — to open the plan
256
- in the default browser on `127.0.0.1`. The page is a review surface: a sticky
257
- header with the plan title, theme toggle, and Save/Close; a sticky **section
258
- outline** on the left; and the plan with each comment rendered **inline under
259
- the block it anchors to**. Every block has a **+ Comment** action; select text
260
- first to quote it. **Save feedback** writes the comments into the plan file and
261
- returns them to the model.
260
+ in the default browser on `127.0.0.1`. The page is a reading-first review
261
+ surface: a sticky header carrying the plan's H1 (falling back to the file-name
262
+ slug), a live status chip (connection, comment count, and unsaved changes), the
263
+ theme toggle, and Save/Close, over a hairline reading-progress bar. A sticky
264
+ side panel shows the plan's step progress and comment count above a **section
265
+ outline**, and the document is GitHub's Markdown content column — one centred
266
+ measure of at most 1012px with a comment rail in its left margin. Every block
267
+ reveals a **+ Comment** action on hover; select
268
+ text first to quote it. **Save feedback** writes the comments into the plan
269
+ file and returns them to the model.
262
270
 
263
271
  - **Shared theme.** The page uses the same Bytefly tokens, base rules, and
264
272
  syntax-token colors as `file-browse` (`lib/web/`), follows the system light/dark
265
273
  preference until the header toggle sets an explicit choice, and remembers that
266
274
  choice under the shared `pi-web-theme` key.
267
- - **Section outline.** Headings become a sticky outline, labelled in the panel,
268
- with jump links and an active-section highlight; the panel collapses into a
269
- disclosure on narrow screens.
275
+ - **One content column, one rail.** The document is GitHub's Markdown content
276
+ column (a centred measure of at most 1012px), so a plan reads the way the same
277
+ file does on GitHub; each block carries its `+ Comment` button and
278
+ comment-count badge
279
+ in a rail in the left margin, so annotating never reflows the text.
280
+ - **Section outline and facts.** Headings become a sticky outline with jump
281
+ links, an active-section highlight, and a per-section comment count; the panel
282
+ above it reports the plan's checkbox steps (`n/m`) and total comments.
270
283
  - **Inline threads.** A comment renders directly under the block it anchors to,
271
- indented as a thread, so it reads in place and the plan keeps one measure at
272
- every width. There is no separate rail or grid to reflow.
284
+ labelled and indented as a thread, so it reads in place and the plan keeps one
285
+ measure at every width. There is no separate column to reflow.
286
+ - **Keyboard first.** `j`/`k` move to the next or previous block, `c` comments
287
+ on the current block, `s` saves, `Esc` closes the composer, and `?` opens a
288
+ shortcut sheet; the header's `?` button opens the same sheet.
289
+ - **Markdown that reads.** Headings, lists, task items, blockquotes, fenced
290
+ code, GFM tables, `---` rules, and bare-URL autolinks render; tables honour the
291
+ alignment row and
292
+ scroll horizontally when narrow.
273
293
  - **Highlighted code.** Fenced code is tokenized by the shared
274
294
  `lib/highlight.ts`; the client copies only text and `tok-*` spans through
275
295
  `DOMParser`, so a code line is never parsed as markup.
@@ -277,18 +297,24 @@ returns them to the model.
277
297
  - **The file is the store.** Each comment is written as
278
298
  `> [!REVIEW] <comment>` on the line after the block it anchors to. Saving is
279
299
  idempotent: the previous markers are stripped first, so a second save replaces
280
- rather than appends. The browser keeps no copy of its own.
300
+ rather than appends. Stripping is fence-aware: a `> [!REVIEW]` line inside a
301
+ code block is documentation, not feedback. The browser keeps no copy of its own.
281
302
  - **Anchored to source.** `blocks.ts` splits the plan (fence-aware) into
282
303
  source-line ranges; a comment is placed after the whole anchored block, so a
283
304
  fenced code block is never split. A save re-checks the file against what the
284
- page rendered and answers `409` (reload) if it changed underneath.
305
+ page rendered and answers `409`; the page then offers a **Reload** action,
306
+ because only a reload can re-anchor the comments to the new text.
285
307
  - **Back to the model.** A save from the review screen returns `refined: true`
286
308
  with the comments, so the model stays in plan mode and revises the same file.
287
309
  `/plan review` sends them as a user message (a plan-mode revision or a plain
288
310
  update, depending on the mode).
289
311
  - **Loopback and tokenized.** The server binds `127.0.0.1`, carries a random
290
- per-session token in the URL, checks the `Host` header, serves only `GET` and
291
- `POST`, and caps the request body. It is closed on save, cancel, tab close, or
312
+ per-session token in the URL, rejects a missing or foreign `Host` header,
313
+ serves only `GET` and `POST`, and caps the request body. A save or cancel
314
+ resolves the session only after its HTTP response is delivered, so the browser
315
+ never sees a false "Failed to fetch". The last bound port is remembered per
316
+ plan, so reviewing the same plan again reuses the origin (and the browser's
317
+ saved theme with it). It is closed on save, cancel, tab close, or
292
318
  `session_shutdown`.
293
319
 
294
320
  | Route | Result |
@@ -312,6 +338,9 @@ returns them to the model.
312
338
  turn cannot drive it.
313
339
  - A browser that never connects leaves the review waiting until the user
314
340
  cancels, closes the tab, or aborts the turn.
341
+ - Feedback stripping follows fenced code blocks; an unterminated fence makes the
342
+ rest of the file code, so a marker-looking line after it is treated as content
343
+ rather than stored feedback.
315
344
 
316
345
  ## Non-goals
317
346
 
@@ -386,12 +415,13 @@ returns them to the model.
386
415
  existing heading-only split cannot, and the page renders text with DOM nodes
387
416
  so review never widens the plan's trust surface. The browser opener is shared
388
417
  through `lib/browser.ts` rather than imported from `file-browse`.
389
- - **Comments render in the document flow, not a rail.** The anchor is already a
390
- source line, so the browser needs no paired rail cell: a thread is appended
391
- after its block and the plan keeps one measure at every width. That removes
392
- the two-column grid, the `bf-review--no-rail` collapse, and the breakpoint
393
- ladder, and matches the TUI, where neither review header carries a progress
394
- counter.
418
+ - **Comments render in the document flow, not a second column.** The anchor is
419
+ already a source line, so the browser needs no paired column: a thread is
420
+ appended after its block and the plan keeps one measure at every width. The
421
+ per-block affordance lives in a narrow rail inside the measure's left margin,
422
+ so annotating never moves the text. That removes the two-column grid, the
423
+ `bf-review--no-rail` collapse, and the breakpoint ladder, and matches the TUI,
424
+ where neither review header carries a progress counter.
395
425
 
396
426
  ## Files
397
427
 
@@ -407,15 +437,15 @@ returns them to the model.
407
437
  | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`. |
408
438
  | `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
409
439
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, section jump, scroll gauge, approve/refine/browser/keep). |
410
- | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, blanks). |
440
+ | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, tables, rules, blanks). |
411
441
  | `feedback.ts` | Browser-comment codec: `stripFeedback`, `parseFeedback`, `formatFeedback`, `feedbackSummary`, and payload validation. |
412
442
  | `web.ts` | `createPlanWebReview`: the loopback server, the section outline and code highlighting, and the save/cancel/abort session. |
413
443
  | `web-router.ts` | Pure request handler: token/`Host`/method checks and the review routes. |
414
- | `web-page.ts` | The review page shell (header, section outline), its data island, and inline CSS. |
415
- | `web-client.ts` | The `/client.js` source (block/thread rendering, outline, inline comments, selection quotes, highlighted code, save/cancel). |
444
+ | `web-page.ts` | The review page shell (header, reading progress, review facts, section outline, shortcut sheet), its data island, and inline CSS. |
445
+ | `web-client.ts` | The `/client.js` source (block/thread rendering, the comment rail, outline and facts, inline comments, selection quotes, tables, highlighted code, keyboard nav, save/cancel). |
416
446
  | `path-guard.ts` | Recursive path-argument detection for the read-only backstop. |
417
447
  | [`../../lib/browser.ts`](../../lib/browser.ts) | Cross-platform default-browser opener (shared with `file-browse`). |
418
448
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer (shared with `file-browse`). |
419
449
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | Shared Bytefly web brand system: tokens, base rules, `tok-*` colors, layout, the bootstrap, the toggle wiring, and shared markup. |
420
450
  | `../../lib/policy.ts` | Shared read-only capability policy (default-deny + `readOnlyHint`). |
421
- | `../../lib/tui.ts` | Shared screen header, viewport-row helper, and styled key-hint footer. |
451
+ | `../../lib/tui/chrome.ts` | Shared screen header, viewport-row helper, and styled key-hint footer. |
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /** How a block is rendered and grouped. */
18
- export type PlanBlockKind = "heading" | "paragraph" | "list" | "code" | "quote" | "blank";
18
+ export type PlanBlockKind = "heading" | "paragraph" | "list" | "code" | "quote" | "table" | "hr" | "blank";
19
19
 
20
20
  /** A contiguous, source-line-anchored chunk of a plan. */
21
21
  export interface PlanBlock {
@@ -36,6 +36,25 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
36
36
  const LIST_ITEM = /^( *)([-*+]|\d+[.)])(\s+)/;
37
37
  /** A blockquote line. */
38
38
  const QUOTE = /^ {0,3}>/;
39
+ /** A thematic break (`---`, `***`, `___`, spaced or not). */
40
+ const THEMATIC_BREAK = /^ {0,3}(?:(?:\* *){3,}|(?:- *){3,}|(?:_ *){3,})$/;
41
+ /** A line that can be a table row: it carries at least one pipe. */
42
+ const TABLE_ROW = /\|/;
43
+ /** A GFM alignment row (`| --- | :--: |`), with or without edge pipes. */
44
+ const TABLE_DELIM = /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/;
45
+
46
+ /** Whether a table starts at `index`: a pipe row followed by an alignment row. */
47
+ function startsTable(lines: string[], index: number): boolean {
48
+ const header = lines[index];
49
+ const delim = lines[index + 1];
50
+ return (
51
+ header !== undefined &&
52
+ delim !== undefined &&
53
+ TABLE_ROW.test(header) &&
54
+ delim.includes("-") &&
55
+ TABLE_DELIM.test(delim)
56
+ );
57
+ }
39
58
 
40
59
  /** Leading spaces and tabs of a line, in columns (tab = 4). */
41
60
  function indentOf(line: string): number {
@@ -102,6 +121,21 @@ export function planBlocks(plan: string): PlanBlock[] {
102
121
  continue;
103
122
  }
104
123
 
124
+ if (startsTable(lines, i)) {
125
+ let end = i + 1;
126
+ // The header and alignment rows, then every contiguous pipe row.
127
+ while (end + 1 < lines.length && lines[end + 1].trim() !== "" && TABLE_ROW.test(lines[end + 1])) end += 1;
128
+ push(i, end, "table");
129
+ i = end + 1;
130
+ continue;
131
+ }
132
+
133
+ if (THEMATIC_BREAK.test(line)) {
134
+ push(i, i, "hr");
135
+ i += 1;
136
+ continue;
137
+ }
138
+
105
139
  const item = line.match(LIST_ITEM);
106
140
  if (item) {
107
141
  const indent = item[1].length;
@@ -123,7 +157,15 @@ export function planBlocks(plan: string): PlanBlock[] {
123
157
  let end = i;
124
158
  while (end + 1 < lines.length) {
125
159
  const next = lines[end + 1];
126
- if (next.trim() === "" || FENCE.test(next) || HEADING.test(next) || QUOTE.test(next) || LIST_ITEM.test(next)) {
160
+ if (
161
+ next.trim() === "" ||
162
+ FENCE.test(next) ||
163
+ HEADING.test(next) ||
164
+ QUOTE.test(next) ||
165
+ LIST_ITEM.test(next) ||
166
+ THEMATIC_BREAK.test(next) ||
167
+ startsTable(lines, end + 1)
168
+ ) {
127
169
  break;
128
170
  }
129
171
  end += 1;
@@ -40,11 +40,25 @@ export const MAX_QUOTE_LENGTH = 500;
40
40
  /** A stored feedback line, capturing its comment. */
41
41
  const MARKER_LINE = /^\s*>\s*\[!REVIEW\]\s?(.*)$/;
42
42
 
43
+ /**
44
+ * Source lines inside a fenced code block. A marker-looking line there is
45
+ * documentation, not stored feedback, so it is never stripped or parsed.
46
+ */
47
+ function fencedLines(plan: string): Set<number> {
48
+ const fenced = new Set<number>();
49
+ for (const block of planBlocks(plan)) {
50
+ if (block.kind !== "code") continue;
51
+ for (let line = block.start; line <= block.end; line++) fenced.add(line);
52
+ }
53
+ return fenced;
54
+ }
55
+
43
56
  /** Remove every stored feedback line, leaving the plan body as authored. */
44
57
  export function stripFeedback(plan: string): string {
58
+ const fenced = fencedLines(plan);
45
59
  return plan
46
60
  .split("\n")
47
- .filter((line) => !MARKER_LINE.test(line))
61
+ .filter((line, index) => fenced.has(index) || !MARKER_LINE.test(line))
48
62
  .join("\n");
49
63
  }
50
64
 
@@ -53,17 +67,18 @@ export function stripFeedback(plan: string): string {
53
67
  * marker in the stripped plan, so a comment keeps its place across reloads.
54
68
  */
55
69
  export function parseFeedback(plan: string): PlanAnnotation[] {
70
+ const fenced = fencedLines(plan);
56
71
  const clean: string[] = [];
57
72
  const markers: Array<{ after: number; comment: string }> = [];
58
- for (const line of plan.split("\n")) {
59
- const match = MARKER_LINE.exec(line);
73
+ plan.split("\n").forEach((line, index) => {
74
+ const match = fenced.has(index) ? null : MARKER_LINE.exec(line);
60
75
  if (match) {
61
76
  const comment = match[1].trim();
62
77
  if (comment) markers.push({ after: clean.length, comment });
63
- continue;
78
+ return;
64
79
  }
65
80
  clean.push(line);
66
- }
81
+ });
67
82
  if (markers.length === 0) return [];
68
83
 
69
84
  const blocks = planBlocks(clean.join("\n"));
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import { hasPathInput } from "./path-guard.ts";
23
23
  import { isExtensionEnabled } from "../../lib/env.ts";
24
24
  import { ASK_USER_QUESTION_TOOL, SUBAGENT_READ_ONLY_PARAM, SUBAGENT_TOOL } from "../../lib/tool-names.ts";
25
- import { STATUS_KEYS } from "../../lib/ui.ts";
25
+ import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
26
26
  import { planModeNotice, registerCommands } from "./commands.ts";
27
27
  import { checkReadOnlyGit } from "./git-bash.ts";
28
28
  import {
@@ -20,7 +20,8 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
20
20
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { sanitizeLine } from "../../lib/format.ts";
22
22
  import type { ReadOnlyPolicy } from "../../lib/policy.ts";
23
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
23
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
24
+ import { paint } from "../../lib/tui/tokens.ts";
24
25
  import { createPlanStore, type PlanStore } from "./plans.ts";
25
26
  import type { PlanModeEntry } from "./types.ts";
26
27
 
@@ -143,7 +144,7 @@ export function createPlanRuntime(
143
144
  lastPlanPath ? `${GLYPHS.plan} plan · ${statusName(lastPlanPath)}` : `${GLYPHS.plan} plan`;
144
145
 
145
146
  const setStatus = (ctx: ExtensionContext): void => {
146
- ctx.ui.setStatus(STATUS_KEYS.planMode, enabled ? ctx.ui.theme.fg("warning", statusText()) : undefined);
147
+ ctx.ui.setStatus(STATUS_KEYS.planMode, enabled ? paint(ctx.ui.theme, "warn", statusText()) : undefined);
147
148
  };
148
149
 
149
150
  const setEnabled = (next: boolean, ctx: ExtensionContext | undefined, persist: boolean): void => {
@@ -22,8 +22,9 @@ import { Type, type Static } from "typebox";
22
22
  import { sanitizeLine } from "../../lib/format.ts";
23
23
  import { askHuman } from "../../lib/interaction.ts";
24
24
  import { TODO_TOOL } from "../../lib/tool-names.ts";
25
- import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui.ts";
26
- import { expandHint } from "../../lib/ui.ts";
25
+ import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui/chrome.ts";
26
+ import { paint } from "../../lib/tui/tokens.ts";
27
+ import { expandHint } from "../../lib/tui/glyphs.ts";
27
28
  import { READ_ONLY_SUMMARY } from "./policy.ts";
28
29
  import { feedbackSummary } from "./feedback.ts";
29
30
  import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEPS_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
@@ -238,7 +239,7 @@ export function registerTools(
238
239
 
239
240
  renderCall(_args, theme, context) {
240
241
  const text = reuseText(context?.lastComponent);
241
- text.setText(theme.fg("toolTitle", theme.bold(`${ENTER_TOOL} `)) + theme.fg("muted", "requested plan mode"));
242
+ text.setText(theme.fg("toolTitle", theme.bold(`${ENTER_TOOL} `)) + paint(theme, "meta", "requested plan mode"));
242
243
  return text;
243
244
  },
244
245
 
@@ -250,10 +251,10 @@ export function registerTools(
250
251
  text.setText(first?.type === "text" ? first.text : "");
251
252
  return text;
252
253
  }
253
- if (details.unavailable) text.setText(theme.fg("warning", "No UI to confirm plan mode"));
254
- else if (details.already) text.setText(theme.fg("muted", "Already in plan mode"));
255
- else if (details.entered) text.setText(theme.fg("success", "✓ Plan mode enabled"));
256
- else text.setText(theme.fg("muted", "Stayed in normal mode"));
254
+ if (details.unavailable) text.setText(paint(theme, "warn", "No UI to confirm plan mode"));
255
+ else if (details.already) text.setText(paint(theme, "meta", "Already in plan mode"));
256
+ else if (details.entered) text.setText(paint(theme, "ok", "✓ Plan mode enabled"));
257
+ else text.setText(paint(theme, "meta", "Stayed in normal mode"));
257
258
  return text;
258
259
  },
259
260
  });
@@ -315,7 +316,7 @@ export function registerTools(
315
316
  renderCall(args, theme, context) {
316
317
  const text = reuseText(context?.lastComponent);
317
318
  const title = (args as Partial<WritePlanArgs>).title;
318
- const suffix = title ? theme.fg("muted", sanitizeLine(title)) : theme.fg("muted", "plan");
319
+ const suffix = title ? paint(theme, "meta", sanitizeLine(title)) : paint(theme, "meta", "plan");
319
320
  text.setText(theme.fg("toolTitle", theme.bold(`${WRITE_PLAN_TOOL} `)) + suffix);
320
321
  return text;
321
322
  },
@@ -328,7 +329,7 @@ export function registerTools(
328
329
  text.setText(first?.type === "text" ? first.text : "");
329
330
  return text;
330
331
  }
331
- text.setText(theme.fg("success", "✓ Saved plan ") + theme.fg("muted", details.relativePath));
332
+ text.setText(paint(theme, "ok", "✓ Saved plan ") + paint(theme, "meta", details.relativePath));
332
333
  return text;
333
334
  },
334
335
  });
@@ -508,7 +509,7 @@ export function registerTools(
508
509
 
509
510
  renderCall(_args, theme, context) {
510
511
  const text = reuseText(context?.lastComponent);
511
- text.setText(theme.fg("toolTitle", theme.bold(`${EXIT_TOOL} `)) + theme.fg("muted", "submitted a plan"));
512
+ text.setText(theme.fg("toolTitle", theme.bold(`${EXIT_TOOL} `)) + paint(theme, "meta", "submitted a plan"));
512
513
  return text;
513
514
  },
514
515
 
@@ -521,25 +522,25 @@ export function registerTools(
521
522
  return text;
522
523
  }
523
524
  const heading = details.unavailable
524
- ? theme.fg("warning", "No UI to approve the plan")
525
+ ? paint(theme, "warn", "No UI to approve the plan")
525
526
  : details.approved
526
- ? theme.fg("success", "✓ Plan approved")
527
+ ? paint(theme, "ok", "✓ Plan approved")
527
528
  : details.refined
528
- ? theme.fg("accent", "Refining the plan")
529
- : theme.fg("warning", "Plan not approved");
529
+ ? paint(theme, "accent", "Refining the plan")
530
+ : paint(theme, "warn", "Plan not approved");
530
531
  const extra =
531
532
  details.refined && details.refinement
532
- ? `\n${theme.fg("muted", `↳ ${sanitizePlanText(details.refinement)}`)}`
533
+ ? `\n${paint(theme, "meta", `↳ ${sanitizePlanText(details.refinement)}`)}`
533
534
  : "";
534
535
  const shown = details.relativePath ?? details.planPath;
535
- const path = shown ? `\n${theme.fg("muted", shown)}` : "";
536
+ const path = shown ? `\n${paint(theme, "meta", shown)}` : "";
536
537
  const planText = sanitizePlanText(details.plan);
537
538
  let out = `${heading}${extra}${path}`;
538
539
  if (planText) {
539
540
  const { body, more } = expanded ? { body: planText, more: 0 } : preview(planText);
540
541
  // A blank line separates the header block from the plan body.
541
542
  out += `\n\n${theme.fg("toolOutput", body)}`;
542
- if (more > 0) out += `\n${theme.fg("dim", `… ${more} more lines`)} ${expandHint(theme)}`;
543
+ if (more > 0) out += `\n${paint(theme, "faint", `… ${more} more lines`)} ${expandHint(theme)}`;
543
544
  }
544
545
  text.setText(out);
545
546
  return text;
@@ -609,7 +610,7 @@ export function registerTools(
609
610
  : updates && updates.length > 1
610
611
  ? `${updates.length} steps`
611
612
  : "steps";
612
- text.setText(theme.fg("toolTitle", theme.bold(`${SET_PLAN_STEPS_TOOL} `)) + theme.fg("muted", label));
613
+ text.setText(theme.fg("toolTitle", theme.bold(`${SET_PLAN_STEPS_TOOL} `)) + paint(theme, "meta", label));
613
614
  return text;
614
615
  },
615
616
 
@@ -625,10 +626,10 @@ export function registerTools(
625
626
  const pending = details.updates.filter((update) => update.status === "pending").map((u) => u.step);
626
627
  const heading =
627
628
  completed.length > 0
628
- ? theme.fg("success", `✓ ${stepLabel(completed)} done`) +
629
- (pending.length > 0 ? theme.fg("muted", ` · ${stepLabel(pending)} reopened`) : "")
630
- : theme.fg("muted", `${stepLabel(pending)} reopened`);
631
- text.setText(heading + theme.fg("dim", ` · ${details.done}/${details.total}`));
629
+ ? paint(theme, "ok", `✓ ${stepLabel(completed)} done`) +
630
+ (pending.length > 0 ? paint(theme, "meta", ` · ${stepLabel(pending)} reopened`) : "")
631
+ : paint(theme, "meta", `${stepLabel(pending)} reopened`);
632
+ text.setText(heading + paint(theme, "faint", ` · ${details.done}/${details.total}`));
632
633
  return text;
633
634
  },
634
635
  });