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

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ context across sessions. `my-pi-agent` is a single [Pi](https://pi.dev) package
11
11
  that adds sixteen extensions and two themes — install the pack, turn off what
12
12
  you do not want, and keep using Pi unchanged.
13
13
 
14
- <img src="./assets/bytefly-chrome.png" alt="A terminal window running the Pi TUI with the bytefly header, hint row, context and loaded extensions, a turn separator, and the status bar" width="100%">
14
+ ![A terminal window running the Pi TUI with the bytefly header, hint row, context and loaded extensions, a turn separator, and the status bar](assets/bytefly-chrome.png)
15
15
 
16
16
  ## Contents
17
17
 
package/README.zh-CN.md CHANGED
@@ -10,7 +10,7 @@
10
10
  `my-pi-agent` 是一个 [Pi](https://pi.dev) 包,新增十六个扩展和两个主题 —— 安装
11
11
  整个包,关掉你不需要的部分,其余照常使用 Pi。
12
12
 
13
- <img src="./assets/bytefly-chrome.png" alt="一个运行 Pi TUI 的终端窗口,包含 bytefly 头部、提示行、上下文与已加载扩展、回合分隔线以及状态栏" width="100%">
13
+ ![一个运行 Pi TUI 的终端窗口,包含 bytefly 头部、提示行、上下文与已加载扩展、回合分隔线以及状态栏](assets/bytefly-chrome.png)
14
14
 
15
15
  ## 目录
16
16
 
@@ -36,9 +36,11 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
36
36
  tables, code, inline and reference links, and images, with a one-click
37
37
  **raw** view (`?raw=1`). Tables size their columns to the content and scroll
38
38
  when wide, and named and numeric character references are decoded. Fenced code
39
- blocks are syntax-highlighted when the fence names a supported language. Raw
40
- HTML in the source is escaped, never executed, and external images are shown
41
- as links rather than fetched.
39
+ blocks are syntax-highlighted when the fence names a supported language.
40
+ Headings get stable anchor ids, so in-document `#anchor` links and a table of
41
+ contents work, and a document with four or more headings gets an **On this
42
+ page** outline. Raw HTML in the source is escaped, never executed, and
43
+ external images are shown as links rather than fetched.
42
44
  - **Sortable listings.** The Name / Size / Modified headers sort the current
43
45
  directory, directories first, and the choice is remembered per browser.
44
46
  - **Copy and wrap.** Copy a file's root-relative path or its contents, and
@@ -209,8 +211,12 @@ document and the project document (project wins):
209
211
  the full `[text][id]`, collapsed `[text][]`, and shortcut `[text]` forms, and
210
212
  numeric and common named character references are resolved. The Rendered/Raw
211
213
  switch is a server-side `?raw=1`
212
- link, so it works without JavaScript. Set `markdown: false` to fall back to
213
- the plain-text view. A fenced code block whose info string names a supported
214
+ link, so it works without JavaScript. Headings are demoted one level so the
215
+ card title stays the page's only `h1`, and each gets a slug `id` (unique in
216
+ the document); `renderMarkdownDocument` returns the heading list and `html.ts`
217
+ renders an **On this page** disclosure for four or more headings. Set
218
+ `markdown: false` to fall back to the plain-text view. A fenced code block
219
+ whose info string names a supported
214
220
  language is highlighted through `lib/highlight.ts` (either an extension or a
215
221
  long-form alias, `fenceLanguage`); an absent or unknown language stays plain.
216
222
  - **Syntax highlighting.** `lib/highlight.ts` is a first-party, escape-first
@@ -22,7 +22,7 @@ import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode }
22
22
  import type { GitStatus } from "./git.ts";
23
23
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
24
24
  import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
25
- import { renderMarkdown } from "./markdown.ts";
25
+ import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
26
26
  import { encodePath } from "./paths.ts";
27
27
 
28
28
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -78,6 +78,13 @@ ${WEB_COMPONENTS_CSS}
78
78
  .browse-broken{color:var(--muted);text-decoration:line-through}
79
79
  .browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
80
80
  .browse-when{display:none}
81
+ .browse-md-outline{max-width:80ch;margin:0 0 var(--space-3) var(--space-4);border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--fs-sm)}
82
+ .browse-md-outline>summary{padding:var(--space-2) var(--space-3);cursor:pointer;color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs)}
83
+ .browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
84
+ .browse-md-outline a{display:block;padding:2px 0}
85
+ .browse-md-outline a.lvl1{padding-left:var(--space-3)}
86
+ .browse-md-outline a.lvl2{padding-left:var(--space-5)}
87
+ .browse-md-outline a.lvl3{padding-left:var(--space-7)}
81
88
  body.palette-open{overflow:hidden}
82
89
  @media (max-width:520px){
83
90
  .bf-toolbar .bf-theme-toggle{margin-left:auto}
@@ -253,6 +260,20 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
253
260
  return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
254
261
  }
255
262
 
263
+ /** An "on this page" outline from the headings the Markdown renderer emitted. */
264
+ function renderOutline(headings: MarkdownHeading[]): string {
265
+ if (headings.length < 4) return "";
266
+ const base = Math.min(...headings.map((heading) => heading.level));
267
+ const items = headings
268
+ .map((heading) => {
269
+ const depth = Math.min(heading.level - base, 3);
270
+ const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
271
+ return `<li><a class="lvl${depth}" href="#${heading.id}">${escapeHtml(label)}</a></li>`;
272
+ })
273
+ .join("");
274
+ return `<details class="browse-md-outline"><summary>On this page</summary><ul>${items}</ul></details>`;
275
+ }
276
+
256
277
  /** The card-head controls: Markdown mode, line wrapping, and copy actions. */
257
278
  function renderFileActions(page: FilePage): string {
258
279
  const isMarkdown = page.file.kind === "markdown";
@@ -275,7 +296,7 @@ function renderFileActions(page: FilePage): string {
275
296
  }
276
297
  if (page.file.kind === "text" || isMarkdown) {
277
298
  groups.push(
278
- `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy</button>`,
299
+ `<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy contents</button>`,
279
300
  );
280
301
  }
281
302
  groups.push(
@@ -296,10 +317,12 @@ export function renderFilePage(page: FilePage): string {
296
317
  let content: string;
297
318
  let note = "";
298
319
  if (page.file.kind === "text" || page.file.kind === "markdown") {
299
- content =
300
- page.file.kind === "markdown" && !page.markdownRaw
301
- ? `<div class="bf-md">${renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel))}</div>`
302
- : `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
320
+ if (page.file.kind === "markdown" && !page.markdownRaw) {
321
+ const rendered = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
322
+ content = `${renderOutline(rendered.headings)}<article class="bf-md">${rendered.html}</article>`;
323
+ } else {
324
+ content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
325
+ }
303
326
  if (page.file.truncated)
304
327
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
305
328
  } else if (page.file.kind === "image") {
@@ -14,6 +14,10 @@
14
14
  * External images are rendered as links (the page CSP is `img-src 'self' data:`)
15
15
  * and non-http(s) schemes such as `javascript:` are dropped.
16
16
  *
17
+ * Headings get a stable anchor id and are demoted one level so the embedding
18
+ * page keeps the only `h1`; `renderMarkdownDocument` returns them for an
19
+ * outline.
20
+ *
17
21
  * Kept free of the page renderer: it imports only `lib/escape.ts`,
18
22
  * `lib/highlight.ts`, and `paths.ts`, so `html.ts` can compose it without a
19
23
  * module cycle.
@@ -35,16 +39,69 @@ interface LinkDefinition {
35
39
  title?: string;
36
40
  }
37
41
 
38
- /** Per-document render state: the resolution base and the reference definitions. */
42
+ /** A heading the renderer emitted, in document order. */
43
+ export interface MarkdownHeading {
44
+ /** The rendered tag level, already demoted and capped at `h6`. */
45
+ level: number;
46
+ /** The anchor id, unique within the document. */
47
+ id: string;
48
+ /** The heading's raw inline Markdown. */
49
+ text: string;
50
+ }
51
+
52
+ /**
53
+ * Per-document render state: the resolution base, the reference definitions,
54
+ * and the headings emitted so far.
55
+ */
39
56
  interface Context {
40
57
  baseDir: string;
41
58
  defs: Map<string, LinkDefinition>;
59
+ headings: MarkdownHeading[];
60
+ slug(text: string): string;
61
+ }
62
+
63
+ /**
64
+ * Render a Markdown source string to a safe HTML fragment, plus the headings it
65
+ * emitted so a caller can build an outline. Headings carry an anchor id and are
66
+ * demoted one level, so the embedding page keeps the only `h1`.
67
+ */
68
+ export function renderMarkdownDocument(source: string, baseDir = ""): { html: string; headings: MarkdownHeading[] } {
69
+ const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
70
+ const context = createContext(baseDir, defs);
71
+ return { html: renderBlocks(body, context), headings: context.headings };
42
72
  }
43
73
 
44
74
  /** Render a Markdown source string to a safe HTML fragment. */
45
75
  export function renderMarkdown(source: string, baseDir = ""): string {
46
- const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
47
- return renderBlocks(body, { baseDir, defs });
76
+ return renderMarkdownDocument(source, baseDir).html;
77
+ }
78
+
79
+ /** Turn heading text into a stable anchor id, dropping Markdown punctuation. */
80
+ function headingSlug(text: string): string {
81
+ const plain = text
82
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
83
+ .replace(/[`*_~]/g, "")
84
+ .toLowerCase()
85
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
86
+ .replace(/[\s-]+/g, "-")
87
+ .replace(/^-+|-+$/g, "");
88
+ return plain || "section";
89
+ }
90
+
91
+ /** Build the per-document render state, suffixing repeated heading slugs. */
92
+ function createContext(baseDir: string, defs: Map<string, LinkDefinition>): Context {
93
+ const seen = new Map<string, number>();
94
+ return {
95
+ baseDir,
96
+ defs,
97
+ headings: [],
98
+ slug(text: string): string {
99
+ const base = headingSlug(text);
100
+ const count = seen.get(base) ?? 0;
101
+ seen.set(base, count + 1);
102
+ return count === 0 ? base : `${base}-${count}`;
103
+ },
104
+ };
48
105
  }
49
106
 
50
107
  /** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
@@ -135,7 +192,10 @@ function renderBlocks(lines: string[], ctx: Context): string {
135
192
 
136
193
  const heading = matchHeading(line);
137
194
  if (heading) {
138
- out.push(`<h${heading.level}>${renderInline(heading.text, ctx)}</h${heading.level}>`);
195
+ const level = Math.min(heading.level + 1, 6);
196
+ const id = ctx.slug(heading.text);
197
+ ctx.headings.push({ level, id, text: heading.text });
198
+ out.push(`<h${level} id="${id}">${renderInline(heading.text, ctx)}</h${level}>`);
139
199
  i++;
140
200
  continue;
141
201
  }
@@ -403,7 +463,7 @@ function renderListItem(contentLines: string[], ctx: Context): string {
403
463
  const tight = !source.some(isBlank);
404
464
  let body = renderBlocks(source, ctx);
405
465
  if (tight) body = body.replace(/<\/?p>/g, "");
406
- const mark = task ? `<span class="md-check" aria-hidden="true">${done ? "☑" : "☐"}</span> ` : "";
466
+ const mark = task ? `<input class="md-check" type="checkbox" disabled${done ? " checked" : ""}> ` : "";
407
467
  return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
408
468
  }
409
469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -90,7 +90,7 @@
90
90
  "themes": [
91
91
  "./themes/*.json"
92
92
  ],
93
- "image": "./assets/bytefly-chrome.png"
93
+ "image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/bytefly-chrome.png"
94
94
  },
95
95
  "scripts": {
96
96
  "test": "bun test --parallel=4 --timeout 30000",