@gavin-hu/my-pi-agent 0.15.0 → 0.16.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 (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -8,6 +8,10 @@
8
8
  * parent directory. The matched character positions are returned so the client
9
9
  * can highlight them; the palette never sees HTML.
10
10
  *
11
+ * The palette runs this on every keystroke over an index of thousands of paths,
12
+ * so ranking keeps only the best `limit` candidates instead of sorting them all:
13
+ * the highlighted columns are computed for those winners and no others.
14
+ *
11
15
  * Pure and dependency-free so it can be unit-tested without a DOM.
12
16
  */
13
17
 
@@ -24,15 +28,30 @@ export interface FuzzyResult {
24
28
  /** Queries longer than this are treated as no match. */
25
29
  const MAX_QUERY_LENGTH = 256;
26
30
 
27
- /** Score one path against a query; `undefined` when it is not a subsequence. */
28
- export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined {
29
- if (query === "") return { score: 0, positions: [] };
31
+ /** A candidate while it is still unranked: a path and its score. */
32
+ interface Candidate {
33
+ path: string;
34
+ score: number;
35
+ }
36
+
37
+ /** A scan's result; `positions` is collected only when the caller asked for it. */
38
+ interface Scan {
39
+ score: number;
40
+ positions?: number[];
41
+ }
42
+
43
+ /**
44
+ * The one subsequence scan. `collect` fills the matched columns, which the
45
+ * ranking pass does not need and allocates an array for on every candidate.
46
+ */
47
+ function scan(query: string, text: string, collect: boolean): Scan | undefined {
48
+ if (query === "") return collect ? { score: 0, positions: [] } : { score: 0 };
30
49
  if (query.length > MAX_QUERY_LENGTH) return undefined;
31
50
 
32
51
  const lowerQuery = query.toLowerCase();
33
52
  const lowerText = text.toLowerCase();
34
53
  const baseStart = text.lastIndexOf("/") + 1;
35
- const positions: number[] = [];
54
+ const positions: number[] | undefined = collect ? [] : undefined;
36
55
  let score = 0;
37
56
  let cursor = 0;
38
57
  let previous = -2;
@@ -40,7 +59,7 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined
40
59
  for (let index = 0; index < lowerQuery.length; index++) {
41
60
  const found = lowerText.indexOf(lowerQuery[index], cursor);
42
61
  if (found === -1) return undefined;
43
- positions.push(found);
62
+ positions?.push(found);
44
63
 
45
64
  let bonus = 1;
46
65
  if (text[found] === query[index]) bonus += 1;
@@ -55,21 +74,56 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined
55
74
  previous = found;
56
75
  cursor = found + 1;
57
76
  }
58
- return { score, positions };
77
+ return positions ? { score, positions } : { score };
78
+ }
79
+
80
+ /** Score one path against a query; `undefined` when it is not a subsequence. */
81
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined {
82
+ const scanned = scan(query, text, true);
83
+ return scanned ? { score: scanned.score, positions: scanned.positions ?? [] } : undefined;
84
+ }
85
+
86
+ /**
87
+ * The palette's one total order: the higher score first, then the shorter path
88
+ * (a basename match beats a name buried in a directory), then the path itself so
89
+ * two candidates never compare equal.
90
+ */
91
+ function compare(a: Candidate, b: Candidate): number {
92
+ if (a.score !== b.score) return b.score - a.score;
93
+ if (a.path.length !== b.path.length) return a.path.length - b.path.length;
94
+ return a.path.localeCompare(b.path);
95
+ }
96
+
97
+ /** Keep `candidate` in `window` — sorted by {@link compare}, never longer than `limit`. */
98
+ function keepBest(window: Candidate[], candidate: Candidate, limit: number): void {
99
+ let low = 0;
100
+ let high = window.length;
101
+ while (low < high) {
102
+ const middle = (low + high) >> 1;
103
+ if (compare(window[middle], candidate) <= 0) low = middle + 1;
104
+ else high = middle;
105
+ }
106
+ if (low >= limit) return;
107
+ window.splice(low, 0, candidate);
108
+ if (window.length > limit) window.pop();
59
109
  }
60
110
 
61
111
  /**
62
112
  * Rank `paths` against `query` and return the best `limit` matches. An empty
63
113
  * query returns nothing: the palette shows the index size instead.
114
+ *
115
+ * The result is the same list a full sort would produce — `keepBest` inserts on
116
+ * the same order — but the work is bounded by `limit` rather than by the index,
117
+ * and the matched columns are computed for the winners only.
64
118
  */
65
119
  export function fuzzySearch(query: string, paths: readonly string[], limit: number): FuzzyResult[] {
66
120
  const trimmed = query.trim();
67
121
  if (trimmed === "" || limit <= 0) return [];
68
- const scored: Array<FuzzyResult & { score: number }> = [];
122
+ const window: Candidate[] = [];
69
123
  for (const path of paths) {
70
- const match = fuzzyMatch(trimmed, path);
71
- if (match) scored.push({ path, positions: match.positions, score: match.score });
124
+ const scored = scan(trimmed, path, false);
125
+ if (scored === undefined) continue;
126
+ keepBest(window, { path, score: scored.score }, limit);
72
127
  }
73
- scored.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
74
- return scored.slice(0, limit).map(({ path, positions }) => ({ path, positions }));
128
+ return window.map(({ path }) => ({ path, positions: fuzzyMatch(trimmed, path)?.positions ?? [] }));
75
129
  }
@@ -4,30 +4,22 @@
4
4
  * Pure string building: the router reads the filesystem and hands these
5
5
  * renderers plain view models. Everything user-controlled is escaped, links are
6
6
  * percent-encoded per segment, and the only script is the same-origin
7
- * `/app.js`. The shared Bytefly shell and components come from `lib/web`; the
8
- * `browse-*` rules below are the page-only layer.
7
+ * `/app.js`. The shared Bytefly shell and components come from `lib/web`, and
8
+ * the page-only `browse-*` rules from `styles.ts`.
9
9
  */
10
10
 
11
11
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
- import {
13
- brandLockup,
14
- docHead,
15
- type KeyHint,
16
- keysSheet,
17
- SIDEBAR_COLLAPSE_MAX,
18
- WEB_BASE_CSS,
19
- WEB_COMPONENTS_CSS,
20
- WEB_LAYOUT_CSS,
21
- WEB_THEME_CSS,
22
- } from "../../lib/web/index.ts";
12
+ import { brandLockup, docHead, type KeyHint, keysSheet } from "../../lib/web/index.ts";
23
13
  import { escapeHtml } from "../../lib/escape.ts";
24
14
  import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
25
15
  import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
26
16
  import type { DiffFileView, GitStatus } from "./git.ts";
17
+ import type { ChangeIndex } from "./live.ts";
27
18
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
28
19
  import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
29
20
  import { renderMarkdown } from "./markdown.ts";
30
21
  import { encodePath } from "./paths.ts";
22
+ import { BROWSE_CSS } from "./styles.ts";
31
23
 
32
24
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
33
25
 
@@ -51,17 +43,15 @@ function renderGit(git: GitStatus | undefined): string {
51
43
  if (!name) return "";
52
44
  const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
53
45
  const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
54
- return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span>${escapeHtml(name)}</span>`;
46
+ return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span><span class="browse-git__name">${escapeHtml(name)}</span></span>`;
55
47
  }
56
48
 
57
- /** The changed-path index for markers, plus the totals shown in the chip. */
58
- export interface ChangeSummary {
59
- count: number;
60
- insertions: number;
61
- deletions: number;
62
- files: ReadonlySet<string>;
63
- dirs: ReadonlySet<string>;
64
- }
49
+ /**
50
+ * The changed-path index for markers, plus the totals shown in the chip. It is
51
+ * the same index the live status feed sends, so the page and its updates agree
52
+ * on what "changed" means.
53
+ */
54
+ export type ChangeSummary = ChangeIndex;
65
55
 
66
56
  /** Whether a path carries a change marker: a changed file, or a directory above one. */
67
57
  function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
@@ -76,122 +66,59 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
76
66
  : "";
77
67
  }
78
68
 
79
- /** The header's changes chip, linking to the diff overview when there are changes. */
80
- function renderChanges(summary: ChangeSummary | undefined): string {
81
- if (!summary || summary.count <= 0) return "";
82
- const label = `${summary.count} change${summary.count === 1 ? "" : "s"}`;
69
+ /** The live chrome a page renders: how often its stream is sent a frame. */
70
+ export interface LivePage {
71
+ intervalMs: number;
72
+ }
73
+
74
+ /**
75
+ * The header's connection chip. It starts hidden and says "connecting": the
76
+ * client reveals and paints it from the event stream, so a page with JS off — or
77
+ * with live status disabled — never shows a state it cannot keep true.
78
+ *
79
+ * It is deliberately not a `role="status"` region: the header is where a reader
80
+ * looks for the page's state, not a place to announce every change to it.
81
+ */
82
+ function renderLiveChip(live: LivePage | undefined): string {
83
+ if (!live) return "";
84
+ return `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}" hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
85
+ }
86
+
87
+ /**
88
+ * The footer's live line, hidden until the first frame: uptime and requests are
89
+ * server facts the rendered page does not know at build time.
90
+ */
91
+ function renderLiveStatus(): string {
92
+ return `<span class="browse-live-line" id="live-status" hidden></span>`;
93
+ }
94
+
95
+ /**
96
+ * The header's changes chip, linking to the diff overview. With live status on,
97
+ * a hidden shell is always rendered so the chip can appear and disappear as the
98
+ * working tree changes, without the client inserting elements into the header.
99
+ */
100
+ function renderChanges(summary: ChangeSummary | undefined, live: LivePage | undefined): string {
101
+ const count = summary?.count ?? 0;
102
+ if (count <= 0) {
103
+ return live
104
+ ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
105
+ : "";
106
+ }
107
+ const label = `${count} change${count === 1 ? "" : "s"}`;
108
+ const insertions = summary?.insertions ?? 0;
109
+ const deletions = summary?.deletions ?? 0;
83
110
  const totals =
84
- summary.insertions + summary.deletions > 0
85
- ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${summary.insertions}</span> <span class="browse-diff-del">−${summary.deletions}</span></span>`
111
+ insertions + deletions > 0
112
+ ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
86
113
  : "";
87
- const aria = `${label} against HEAD${summary.insertions + summary.deletions > 0 ? `, ${summary.insertions} insertions and ${summary.deletions} deletions` : ""}`;
88
- return `<a class="bf-chip browse-diff" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
114
+ const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
115
+ return `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
89
116
  }
90
117
 
91
118
  function hrefFor(entry: { rel: string; isDir: boolean }): string {
92
119
  return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
93
120
  }
94
121
 
95
- /** The shared shell, components, and the page-only `browse-*` rules. */
96
- const STYLES = `${WEB_BASE_CSS}
97
- ${WEB_THEME_CSS}
98
- ${WEB_LAYOUT_CSS}
99
- ${WEB_COMPONENTS_CSS}
100
- .browse-side{padding:var(--space-3)}
101
- .browse-root{flex:1 1 auto;direction:rtl;text-align:left}
102
- .browse-git{font-family:var(--font-mono)}
103
- .browse-quick-open{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;margin-bottom:var(--space-2);padding:6px var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);color:var(--muted);font:var(--fs-sm) var(--font-ui);cursor:pointer}
104
- .browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
105
- .browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
106
- .browse-side__filters{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)}
107
- .browse-side__filters .bf-field{flex:1 1 auto;min-width:0}
108
- .browse-side__filters .bf-btn{flex:0 0 auto;white-space:nowrap}
109
- .browse-cat{width:16px;height:16px;flex:0 0 16px;color:var(--accent)}
110
- .browse-cat.doc{color:var(--muted)}.browse-cat.img{color:var(--success)}.browse-cat.cfg{color:var(--warn)}.browse-cat.arc{color:var(--warn)}
111
- .browse-heading{margin:0 0 var(--space-3);font-size:var(--fs-lg)}
112
- .browse-listing th.bf-table__size,.browse-listing th.bf-table__when{text-align:right}
113
- .browse-listing th.bf-table__size{width:96px}
114
- .browse-listing th.bf-table__when{width:140px}
115
- .browse-dotfile .bf-table__name a{opacity:.72}
116
- .browse-broken{color:var(--muted);text-decoration:line-through}
117
- .browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
118
- .browse-when{display:none}
119
- body.palette-open{overflow:hidden}
120
- .browse-diff{font-family:var(--font-mono);gap:5px}
121
- .browse-diff:hover{color:var(--accent);text-decoration:none}
122
- .browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
123
- .browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
124
- .browse-diff-totals{font-family:var(--font-mono)}
125
- .browse-diff-add{color:var(--success)}
126
- .browse-diff-del{color:var(--danger)}
127
- .browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
128
- .browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
129
- .browse-diff-stat{margin:0 0 var(--space-3);font:var(--fs-xs)/1.5 var(--font-mono);color:var(--muted);white-space:pre-wrap;overflow-x:auto}
130
- .browse-diff th.browse-diff-status{width:72px}
131
- .browse-diff th.browse-diff-word{width:130px}
132
- .browse-diff-badge{display:inline-block;min-width:16px;text-align:center;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);border-radius:var(--radius-sm);padding:1px 5px;border:1px solid var(--border)}
133
- .browse-diff-badge--M{color:var(--warn);border-color:currentColor}
134
- .browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
135
- .browse-diff-badge--D{color:var(--danger);border-color:currentColor}
136
- .browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
137
- .browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
138
- .browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
139
- .browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
140
- .browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
141
- .bf-diff-line--add{background:var(--success-soft)}
142
- .bf-diff-line--add .browse-diff-sign{color:var(--success)}
143
- .bf-diff-line--del{background:var(--danger-soft)}
144
- .bf-diff-line--del .browse-diff-sign{color:var(--danger)}
145
- .bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
146
- .bf-diff-line--meta{color:var(--muted)}
147
- .bf-diff-line--note{color:var(--muted);font-style:italic}
148
- /* A hunk header pins to the top of the diff viewport while its body scrolls.
149
- Each hunk is a section so the pinned header is bounded by its own body and
150
- cannot stack on the next one. */
151
- .bf-diff-hunk{display:block}
152
- .bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
153
- /* Reading progress, pinned to the sticky header's bottom edge. */
154
- .browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
155
- .browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
156
- /* File and diff pages: a measured reading column inside the card. */
157
- /* The Markdown body fills the card, so the scroll box is the card's width: its
158
- scrollbar sits at the card edge. The measure lives on the blocks themselves:
159
- on .bf-md it would be a flex item with auto inline margins, which is
160
- shrink-to-fit, so a short document collapsed to its widest line. */
161
- .browse-measure{--browse-measure:1012px}
162
- .browse-measure .bf-md{--browse-gutter:max(0px,calc((100% - var(--browse-measure)) / 2));max-width:none;margin-inline:0;padding-inline:var(--space-5);padding-block:var(--space-4) var(--space-6)}
163
- .browse-measure .bf-md>*{max-width:var(--browse-measure);margin-inline:auto}
164
- /* Tables start at the prose column and take only the gutter to its right, so a
165
- wide window widens them without a dead band on their left. Code stays inside
166
- the measure with the rest of the prose. */
167
- .browse-measure .bf-md>.md-table-frame{max-width:none;padding-left:var(--browse-gutter)}
168
- /* A table wider than the pane scrolls, but an overlay scrollbar is invisible
169
- until it moves; a gradient on the frame marks the columns still out of view.
170
- It has to be the frame: the scroller clips its own content, so a pseudo-element
171
- inside it would scroll away with the table. */
172
- .browse-measure .bf-md>.md-table-frame.browse-table-more::after{content:"";position:absolute;top:0;right:0;bottom:0;width:40px;pointer-events:none;background:linear-gradient(to left,var(--surface) 12%,transparent)}
173
- .bf-card__meta{font-variant-numeric:tabular-nums}
174
- .browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
175
- .browse-diff-summary .browse-diff-add{color:var(--success)}
176
- .browse-diff-summary .browse-diff-del{color:var(--danger)}
177
- .browse-hunks{color:var(--muted)}
178
- /* Wide screens: pin quick open and the filter, and scroll the tree on its own
179
- box. The sidebar is a details element, whose content sits in an anonymous
180
- wrapper with no definite height, so the panel fills the sidebar by absolute
181
- positioning; below the sidebar breakpoint the disclosure keeps normal page
182
- scroll. */
183
- @media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
184
- .browse-sidebar{position:relative;overflow:hidden}
185
- .browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
186
- .browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
187
- }
188
- @media (max-width:820px){
189
- .browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
190
- }
191
- @media (max-width:520px){
192
- .bf-toolbar .bf-theme-toggle{margin-left:auto}
193
- }`;
194
-
195
122
  interface Layout {
196
123
  title: string;
197
124
  rootLabel: string;
@@ -207,6 +134,8 @@ interface Layout {
207
134
  git?: GitStatus;
208
135
  /** Changed-path index for the header chip and the tree markers; omitted when clean. */
209
136
  summary?: ChangeSummary;
137
+ /** Live status chrome; omitted when the channel is off. */
138
+ live?: LivePage;
210
139
  }
211
140
 
212
141
  /** A full two-pane page. */
@@ -214,7 +143,7 @@ function renderLayout(layout: Layout): string {
214
143
  return `<!doctype html>
215
144
  <html lang="en">
216
145
  <head>
217
- ${docHead({ title: layout.title, css: STYLES })}
146
+ ${docHead({ title: layout.title, css: BROWSE_CSS })}
218
147
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%2358a6ff'/%3E%3C/svg%3E">
219
148
  </head>
220
149
  <body class="bf-app">
@@ -226,8 +155,9 @@ ${SPRITE}
226
155
  ${brandLockup("browse")}
227
156
  <span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
228
157
  ${renderGit(layout.git)}
229
- ${renderChanges(layout.summary)}
158
+ ${renderChanges(layout.summary, layout.live)}
230
159
  <span class="bf-chip bf-chip--accent">read-only</span>
160
+ ${renderLiveChip(layout.live)}
231
161
  <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
232
162
  <button type="button" class="bf-btn bf-btn--icon bf-theme-toggle" id="theme-toggle" aria-label="Toggle light and dark theme" title="Toggle light and dark theme"><span class="theme-glyph" aria-hidden="true">☾</span></button>
233
163
  </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
@@ -249,7 +179,7 @@ ${SPRITE}
249
179
  ${layout.main}
250
180
  </main>
251
181
  </div>
252
- <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
182
+ <footer class="bf-app__footer">${layout.live ? renderLiveStatus() : ""}served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
253
183
  ${layout.keys && layout.keys.length > 0 ? keysSheet(layout.keys) : ""}
254
184
  <script src="/app.js" defer></script>
255
185
  </body>
@@ -326,6 +256,8 @@ export interface DirectoryPage {
326
256
  git?: GitStatus;
327
257
  /** Changed-path index for the header chip and markers. */
328
258
  summary?: ChangeSummary;
259
+ /** Live status chrome; omitted when the channel is off. */
260
+ live?: LivePage;
329
261
  }
330
262
 
331
263
  /** Render a directory listing page. */
@@ -354,6 +286,7 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
354
286
  footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
355
287
  git: page.git,
356
288
  summary: page.summary,
289
+ live: page.live,
357
290
  });
358
291
  }
359
292
 
@@ -372,6 +305,8 @@ export interface FilePage {
372
305
  changed?: boolean;
373
306
  /** Changed-path index for the header chip and the tree markers. */
374
307
  summary?: ChangeSummary;
308
+ /** Live status chrome; omitted when the channel is off. */
309
+ live?: LivePage;
375
310
  }
376
311
 
377
312
  /** The root-relative directory of a file, for resolving its Markdown links. */
@@ -490,6 +425,7 @@ export function renderFilePage(page: FilePage): string {
490
425
  footerNote: page.rel,
491
426
  git: page.git,
492
427
  summary: page.summary,
428
+ live: page.live,
493
429
  });
494
430
  }
495
431
 
@@ -614,6 +550,8 @@ export interface DiffPage {
614
550
  tree: TreeNode;
615
551
  git?: GitStatus;
616
552
  summary?: ChangeSummary;
553
+ /** Live status chrome; omitted when the channel is off. */
554
+ live?: LivePage;
617
555
  }
618
556
 
619
557
  /** The change overview: `git diff --stat` plus every changed path. */
@@ -641,6 +579,7 @@ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
641
579
  footerNote: `${page.files.length} changed`,
642
580
  git: page.git,
643
581
  summary: page.summary,
582
+ live: page.live,
644
583
  });
645
584
  }
646
585
 
@@ -661,6 +600,8 @@ export interface FileDiffPage {
661
600
  tree: TreeNode;
662
601
  git?: GitStatus;
663
602
  summary?: ChangeSummary;
603
+ /** Live status chrome; omitted when the channel is off. */
604
+ live?: LivePage;
664
605
  }
665
606
 
666
607
  /** A single file's unified diff, with the scope control and file actions. */
@@ -710,6 +651,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
710
651
  footerNote: `diff · ${modeScope(page.mode)}`,
711
652
  git: page.git,
712
653
  summary: page.summary,
654
+ live: page.live,
713
655
  });
714
656
  }
715
657
 
@@ -719,6 +661,8 @@ export interface ErrorPage {
719
661
  message: string;
720
662
  tree: TreeNode;
721
663
  git?: GitStatus;
664
+ /** Live status chrome; omitted when the channel is off. */
665
+ live?: LivePage;
722
666
  }
723
667
 
724
668
  /** Render a status/error page with the same chrome. */
@@ -732,5 +676,6 @@ export function renderErrorPage(page: ErrorPage): string {
732
676
  main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="/browse/">back to the served folder</a></p></div>`,
733
677
  footerNote: "error",
734
678
  git: page.git,
679
+ live: page.live,
735
680
  });
736
681
  }
@@ -4,7 +4,8 @@
4
4
  * Registers the read-only `/browse` command. It starts a `node:http` server
5
5
  * bound to 127.0.0.1, rooted at the effective cwd, and opens the default
6
6
  * browser at a two-pane tree browser. No tool is registered; the server is
7
- * closed on `session_shutdown`.
7
+ * closed on `session_shutdown`, by `/browse stop`, and once its last page has
8
+ * been gone for the idle grace — a closed window is not a running server.
8
9
  *
9
10
  * Load with: pi --extension ./extensions/file-browse
10
11
  */
@@ -32,6 +33,8 @@ export interface FileBrowserDeps {
32
33
 
33
34
  interface RunningServer {
34
35
  server: FileServer;
36
+ /** The context of the command that started it, used to clear the chip later. */
37
+ ctx: ExtensionContext;
35
38
  }
36
39
 
37
40
  export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
@@ -62,6 +65,17 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
62
65
  return true;
63
66
  };
64
67
 
68
+ /**
69
+ * Close the server once the page that was its only reader has gone: nothing is
70
+ * left to serve, and the chip would otherwise advertise a port that answers no
71
+ * one. `/browse` starts it again on the same session port.
72
+ */
73
+ const stopWhenPageLeft = async (): Promise<void> => {
74
+ const current = running;
75
+ if (!current) return;
76
+ if (await stop(current.ctx)) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
77
+ };
78
+
65
79
  const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
66
80
  const target = args.trim();
67
81
  if (target === "status") {
@@ -121,9 +135,10 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
121
135
  maxFileBytes: config.maxFileBytes,
122
136
  maxTextLines: config.maxTextLines,
123
137
  }),
138
+ onIdle: () => void stopWhenPageLeft(),
124
139
  });
125
140
  if (unpinned) sessionPort = port;
126
- running = { server };
141
+ running = { server, ctx };
127
142
  setBrowseStatus(ctx, server.port);
128
143
  ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
129
144
  if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The live status block of the browse client script.
3
+ *
4
+ * It is a fragment of the same IIFE `client.ts` ships (`LIVE_CLIENT_JS` is
5
+ * interpolated into `CLIENT_JS`), kept in its own module so the client script
6
+ * stays one readable unit per concern. Everything it paints is text it was
7
+ * handed — a branch name, a path, a count — so a repository cannot inject markup
8
+ * into the page through a status frame.
9
+ *
10
+ * It does nothing at all unless the server rendered the connection chip, which
11
+ * is what makes `live: false` and JS-off leave the page exactly as it was.
12
+ */
13
+
14
+ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the header chips, the tree and listing
15
+ // markers, and the footer line. The server owns the cadence; this only paints.
16
+ var liveChip = document.getElementById("live-state");
17
+ if (liveChip) {
18
+ var liveText = liveChip.querySelector(".browse-live__text");
19
+ var liveLine = document.getElementById("live-status");
20
+ var liveGit = document.querySelector(".browse-git");
21
+ var liveChanges = document.getElementById("browse-changes");
22
+ var liveInterval = Number(liveChip.getAttribute("data-interval")) || 3000;
23
+ var liveStale = 0;
24
+ var liveTone = "";
25
+
26
+ function formatSpan(ms) {
27
+ var seconds = Math.max(0, Math.round(ms / 1000));
28
+ if (seconds < 60) return seconds + "s";
29
+ var minutes = Math.floor(seconds / 60);
30
+ if (minutes < 60) return minutes + "m " + String(seconds % 60).padStart(2, "0") + "s";
31
+ return Math.floor(minutes / 60) + "h " + String(minutes % 60).padStart(2, "0") + "m";
32
+ }
33
+
34
+ function setLiveTone(tone, label) {
35
+ liveTone = tone;
36
+ liveChip.className = "bf-chip browse-live" + (tone ? " bf-chip--" + tone : "");
37
+ liveChip.hidden = false;
38
+ if (liveText) liveText.textContent = label;
39
+ }
40
+
41
+ // The chip names its state and never counts: nothing is repainted between
42
+ // frames, and the age of the last one is the footer's uptime to report. Three
43
+ // missed frames on an open connection is a stalled feed, not a live one, so
44
+ // one timeout is armed per frame instead of a second-by-second tick.
45
+ function armLiveStale() {
46
+ window.clearTimeout(liveStale);
47
+ liveStale = window.setTimeout(function () {
48
+ if (liveTone === "success") setLiveTone("warn", "stale");
49
+ }, liveInterval * 3);
50
+ }
51
+
52
+ function paintLiveGit(git) {
53
+ if (!liveGit || !git) return;
54
+ var name = git.branch || git.head || "";
55
+ if (!name) return;
56
+ var aria = git.branch ? "git branch " + git.branch : "git detached at " + git.head;
57
+ liveGit.title = aria;
58
+ liveGit.setAttribute("aria-label", aria);
59
+ var glyph = liveGit.querySelector(".browse-git__glyph");
60
+ if (glyph) glyph.textContent = git.branch ? "\\u2387" : "\\u26A0";
61
+ var label = liveGit.querySelector(".browse-git__name");
62
+ if (label) label.textContent = name;
63
+ }
64
+
65
+ function paintLiveMarks(changes) {
66
+ var files = {};
67
+ var dirs = {};
68
+ ((changes && changes.files) || []).forEach(function (path) { files[path] = true; });
69
+ ((changes && changes.dirs) || []).forEach(function (path) { dirs[path] = true; });
70
+ var rows = document.querySelectorAll(".browse-listing tbody tr[data-path], nav.bf-tree .bf-tree__row[data-path]");
71
+ Array.prototype.forEach.call(rows, function (row) {
72
+ var path = row.getAttribute("data-path");
73
+ var inTree = row.parentElement && row.parentElement.tagName === "LI";
74
+ var isDir = inTree ? !!row.querySelector(".bf-tree__tw--toggle") : row.getAttribute("data-dir") === "1";
75
+ var marked = isDir ? dirs[path] === true : files[path] === true;
76
+ var mark = row.querySelector(".browse-change");
77
+ if (marked && !mark) {
78
+ mark = document.createElement("span");
79
+ mark.className = "browse-change";
80
+ mark.title = "uncommitted changes";
81
+ mark.setAttribute("aria-label", "uncommitted changes");
82
+ mark.textContent = "\\u25CF";
83
+ var name = row.querySelector(".bf-table__name");
84
+ (name || row).appendChild(mark);
85
+ } else if (!marked && mark && mark.parentNode) {
86
+ mark.parentNode.removeChild(mark);
87
+ }
88
+ });
89
+ }
90
+
91
+ function paintLiveChanges(changes) {
92
+ var count = changes ? changes.count : 0;
93
+ if (liveChanges) {
94
+ liveChanges.hidden = count <= 0;
95
+ if (count > 0) {
96
+ var label = count + (count === 1 ? " change" : " changes");
97
+ var totals = (changes.insertions || 0) + (changes.deletions || 0);
98
+ liveChanges.textContent = "";
99
+ var dot = document.createElement("span");
100
+ dot.className = "browse-diff__dot";
101
+ dot.setAttribute("aria-hidden", "true");
102
+ dot.textContent = "\\u25CF";
103
+ liveChanges.appendChild(dot);
104
+ liveChanges.appendChild(document.createTextNode(label));
105
+ if (totals > 0) {
106
+ var sum = document.createElement("span");
107
+ sum.className = "browse-diff-totals";
108
+ var add = document.createElement("span");
109
+ add.className = "browse-diff-add";
110
+ add.textContent = "+" + changes.insertions;
111
+ var del = document.createElement("span");
112
+ del.className = "browse-diff-del";
113
+ del.textContent = "\\u2212" + changes.deletions;
114
+ sum.appendChild(add);
115
+ sum.appendChild(document.createTextNode(" "));
116
+ sum.appendChild(del);
117
+ liveChanges.appendChild(sum);
118
+ }
119
+ var aria = label + " against HEAD";
120
+ if (totals > 0) aria += ", " + changes.insertions + " insertions and " + changes.deletions + " deletions";
121
+ liveChanges.setAttribute("aria-label", aria);
122
+ }
123
+ }
124
+ paintLiveMarks(changes);
125
+ }
126
+
127
+ function paintLiveSnapshot(snapshot) {
128
+ setLiveTone("success", "live");
129
+ armLiveStale();
130
+ if (liveLine) {
131
+ liveLine.hidden = false;
132
+ var requests = Number(snapshot.requests) || 0;
133
+ liveLine.textContent = "up " + formatSpan(Number(snapshot.uptimeMs) || 0) + " \\u00B7 " + requests + (requests === 1 ? " request" : " requests");
134
+ }
135
+ paintLiveGit(snapshot.git);
136
+ paintLiveChanges(snapshot.changes);
137
+ }
138
+
139
+ if (window.EventSource) {
140
+ var liveStream = new EventSource("/events");
141
+ liveStream.addEventListener("open", function () {
142
+ if (liveTone !== "success") setLiveTone("accent", "connecting");
143
+ });
144
+ liveStream.addEventListener("status", function (event) {
145
+ var snapshot;
146
+ try { snapshot = JSON.parse(event.data); } catch (error) { return; }
147
+ paintLiveSnapshot(snapshot);
148
+ });
149
+ liveStream.addEventListener("error", function () {
150
+ setLiveTone("danger", "offline");
151
+ });
152
+ window.addEventListener("pagehide", function () {
153
+ liveStream.close();
154
+ window.clearTimeout(liveStale);
155
+ });
156
+ }
157
+ }
158
+ `;