@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.
- package/README.md +12 -11
- package/README.zh-CN.md +9 -7
- package/extensions/bytefly/README.md +98 -116
- package/extensions/bytefly/notice/component.ts +6 -4
- package/extensions/bytefly/notice/config.ts +16 -2
- package/extensions/bytefly/panel/format.ts +3 -8
- package/extensions/bytefly/panel/join.ts +1 -6
- package/extensions/bytefly/panel/settings.ts +74 -377
- package/extensions/bytefly/panel/tui/entries.ts +16 -14
- package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
- package/extensions/bytefly/panel/tui/frame.ts +2 -2
- package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
- package/extensions/bytefly/panel/tui.ts +4 -6
- package/extensions/bytefly/picker/resources.ts +7 -3
- package/extensions/bytefly/working/register.ts +2 -8
- package/extensions/check/tools.ts +2 -11
- package/extensions/context/README.md +162 -0
- package/extensions/context/collect.ts +53 -0
- package/extensions/context/commands.ts +100 -0
- package/extensions/context/format.ts +82 -0
- package/extensions/context/index.ts +16 -0
- package/extensions/context/report.ts +319 -0
- package/extensions/context/tui.ts +268 -0
- package/extensions/context/types.ts +122 -0
- package/extensions/file-browse/README.md +83 -8
- package/extensions/file-browse/client.ts +3 -1
- package/extensions/file-browse/config.ts +23 -0
- package/extensions/file-browse/files.ts +183 -48
- package/extensions/file-browse/fuzzy.ts +65 -11
- package/extensions/file-browse/html.ts +78 -133
- package/extensions/file-browse/index.ts +17 -2
- package/extensions/file-browse/live-client.ts +158 -0
- package/extensions/file-browse/live.ts +256 -0
- package/extensions/file-browse/markdown.ts +3 -10
- package/extensions/file-browse/raw-html.ts +3 -10
- package/extensions/file-browse/router.ts +33 -8
- package/extensions/file-browse/server.ts +78 -13
- package/extensions/file-browse/styles.ts +124 -0
- package/extensions/file-open/README.md +1 -2
- package/extensions/{document → file-read}/README.md +72 -56
- package/extensions/{document → file-read}/config.ts +12 -9
- package/extensions/{document → file-read}/extract/doc.ts +2 -2
- package/extensions/{document → file-read}/extract/docx.ts +3 -3
- package/extensions/{document → file-read}/extract/file.ts +9 -9
- package/extensions/{document → file-read}/extract/odt.ts +3 -3
- package/extensions/{document → file-read}/extract/pdf.ts +3 -3
- package/extensions/{document → file-read}/extract/rtf.ts +3 -3
- package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
- package/extensions/{document → file-read}/formats.ts +11 -11
- package/extensions/file-read/index.ts +17 -0
- package/extensions/{document → file-read}/paging.ts +15 -13
- package/extensions/{document → file-read}/render.ts +17 -13
- package/extensions/{document → file-read}/schema.ts +7 -7
- package/extensions/{document → file-read}/tool.ts +36 -28
- package/extensions/goal/tools.ts +10 -14
- package/extensions/job/commands.ts +32 -46
- package/extensions/job/runtime.ts +6 -8
- package/extensions/job/tools.ts +7 -26
- package/extensions/memory/commands.ts +30 -44
- package/extensions/memory/tools.ts +6 -14
- package/extensions/plan/tools/enter.ts +6 -7
- package/extensions/plan/tools/exit.ts +6 -7
- package/extensions/plan/tools/steps.ts +6 -7
- package/extensions/plan/tools/write.ts +8 -9
- package/extensions/plan/web-router.ts +2 -12
- package/extensions/plan/web.ts +30 -15
- package/extensions/question/tools.ts +5 -4
- package/extensions/rewind/checklist.ts +10 -3
- package/extensions/rewind/git.ts +27 -14
- package/extensions/rewind/hunk-picker.ts +1 -1
- package/extensions/rewind/path-picker.ts +1 -1
- package/extensions/rewind/review-tui.ts +3 -3
- package/extensions/rewind/snapshot.ts +13 -4
- package/extensions/subagent/background-tui.ts +1 -1
- package/extensions/subagent/background.ts +1 -7
- package/extensions/todo/tools.ts +10 -14
- package/extensions/watch/README.md +5 -5
- package/extensions/watch/commands.ts +33 -47
- package/extensions/watch/runtime.ts +5 -7
- package/extensions/watch/tools.ts +7 -18
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/search/search.ts +9 -18
- package/extensions/wechat/timer.ts +9 -30
- package/extensions/wechat/tools.ts +4 -7
- package/extensions/worktree/status.ts +14 -9
- package/lib/README.md +12 -8
- package/lib/escape.ts +15 -2
- package/lib/git/read.ts +20 -0
- package/lib/http/headers.ts +20 -0
- package/lib/http/server.ts +22 -4
- package/lib/pdf.ts +2 -2
- package/lib/pool.ts +62 -0
- package/lib/requirements.ts +2 -2
- package/lib/screen-session.ts +85 -0
- package/lib/timer.ts +61 -0
- package/lib/tui/README.md +3 -3
- package/lib/tui/columns.ts +33 -8
- package/lib/tui/dock-screen.ts +12 -4
- package/lib/tui/rows.ts +15 -6
- package/lib/tui/rules.ts +4 -8
- package/lib/tui/tool.ts +55 -2
- package/package.json +8 -4
- package/skills/changelog/SKILL.md +136 -0
- package/extensions/document/index.ts +0 -17
- /package/extensions/{document → file-read}/deadline.ts +0 -0
- /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report's data contract.
|
|
3
|
+
*
|
|
4
|
+
* `ContextInputs` is what the host hands over: the effective system prompt, the
|
|
5
|
+
* tool surface, the host's usage estimate, the messages that will be sent, and
|
|
6
|
+
* the branch the compaction history is read from. `ContextReport` is what the
|
|
7
|
+
* pure builder derives from it and what the text detail and the screen render.
|
|
8
|
+
*
|
|
9
|
+
* Host types that the package root does not re-export are named through the
|
|
10
|
+
* extension surface (`ReturnType<…>`) rather than a deep import, so a peer
|
|
11
|
+
* version change fails to typecheck instead of resolving a second copy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
/** The host's context-usage estimate (`ctx.getContextUsage()`). */
|
|
18
|
+
export type ContextUsage = NonNullable<ReturnType<ExtensionContext["getContextUsage"]>>;
|
|
19
|
+
|
|
20
|
+
/** The active branch entries (`sessionManager.getBranch()`). */
|
|
21
|
+
export type BranchEntries = ReturnType<ExtensionContext["sessionManager"]["getBranch"]>;
|
|
22
|
+
|
|
23
|
+
/** Merged global and project settings (`pi.getSettings()`). */
|
|
24
|
+
export type ContextSettings = ReturnType<ExtensionAPI["getSettings"]>;
|
|
25
|
+
|
|
26
|
+
/** Everything the pure report needs; collected once per open or refresh. */
|
|
27
|
+
export interface ContextInputs {
|
|
28
|
+
/** The model in effect, or `null` before one is selected. */
|
|
29
|
+
model: { provider: string; id: string } | null;
|
|
30
|
+
/** The active model's context window, or `0` when unknown. */
|
|
31
|
+
window: number;
|
|
32
|
+
/** The effective system prompt for this turn (`ctx.getSystemPrompt()`). */
|
|
33
|
+
systemPrompt: string;
|
|
34
|
+
/** The active tool surface, with the definition estimate already computed. */
|
|
35
|
+
tools: { active: number; total: number; tokens: number };
|
|
36
|
+
/** The host's estimate; `tokens` is `null` between a compaction and the next reply. */
|
|
37
|
+
usage: ContextUsage | undefined;
|
|
38
|
+
/** The model-visible messages for the next request. */
|
|
39
|
+
messages: readonly AgentMessage[];
|
|
40
|
+
/** The branch the latest compaction entry and its count are read from. */
|
|
41
|
+
branch: BranchEntries;
|
|
42
|
+
/** Merged settings, for the compaction threshold. */
|
|
43
|
+
settings: ContextSettings;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One row of the composition table. */
|
|
47
|
+
export interface ContextBucket {
|
|
48
|
+
/** Stable id; an injected child is `injected:<customType>`. */
|
|
49
|
+
id: string;
|
|
50
|
+
/** Display label, already plain text. */
|
|
51
|
+
label: string;
|
|
52
|
+
/** Estimated tokens. */
|
|
53
|
+
tokens: number;
|
|
54
|
+
/** Share of the composition estimate, `0..100`. */
|
|
55
|
+
share: number;
|
|
56
|
+
/** `0` for a top-level bucket, `1` for an injected child. */
|
|
57
|
+
depth: number;
|
|
58
|
+
/** A short factual suffix: `18 active of 24`, a message count. */
|
|
59
|
+
note?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The latest compaction's effect. */
|
|
63
|
+
export interface CompactionSummary {
|
|
64
|
+
/** `YYYY-MM-DD HH:MMZ` from the entry's ISO timestamp, in UTC. */
|
|
65
|
+
at: string;
|
|
66
|
+
/** The estimate the host recorded before compacting. */
|
|
67
|
+
tokensBefore: number;
|
|
68
|
+
/** The summary's own estimate. */
|
|
69
|
+
summaryTokens: number;
|
|
70
|
+
/** `tokensBefore - summaryTokens`, floored at zero. */
|
|
71
|
+
freed: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the branch remembers about compaction. */
|
|
75
|
+
export interface CompactionHistory {
|
|
76
|
+
/** How many compaction entries the branch holds. */
|
|
77
|
+
count: number;
|
|
78
|
+
/** The newest one, or `null` when the branch has never been compacted. */
|
|
79
|
+
last: CompactionSummary | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One of the largest single blocks in the projected context. */
|
|
83
|
+
export interface LargestBlock {
|
|
84
|
+
/** Estimated tokens. */
|
|
85
|
+
tokens: number;
|
|
86
|
+
/** What kind of message it is: `user`, `assistant`, `tool result`, … */
|
|
87
|
+
role: string;
|
|
88
|
+
/** Sanitized, clipped descriptor (a tool name, the first line, a custom type). */
|
|
89
|
+
label: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The finished report. */
|
|
93
|
+
export interface ContextReport {
|
|
94
|
+
/** The context window, `0` when unknown. */
|
|
95
|
+
window: number;
|
|
96
|
+
/** The host's estimate, `null` between a compaction and the next reply. */
|
|
97
|
+
tokens: number | null;
|
|
98
|
+
/** `tokens / window`, `null` when either is unknown. */
|
|
99
|
+
percent: number | null;
|
|
100
|
+
/** `window - tokens`, `null` when `tokens` is unknown. */
|
|
101
|
+
free: number | null;
|
|
102
|
+
/** `window - reserve - tokens`, `null` when `tokens` is unknown. */
|
|
103
|
+
headroom: number | null;
|
|
104
|
+
/** The resolved `reserveTokens` for the active model. */
|
|
105
|
+
reserve: number;
|
|
106
|
+
/** The resolved `keepRecentTokens` for the active model. */
|
|
107
|
+
keepRecentTokens: number;
|
|
108
|
+
/** Whether the host's automatic compaction is on. */
|
|
109
|
+
enabled: boolean;
|
|
110
|
+
/** `max(0, window - reserve)`: where the host would compact. */
|
|
111
|
+
autoCompactAt: number;
|
|
112
|
+
/** The composition rows, ordered for display. */
|
|
113
|
+
buckets: ContextBucket[];
|
|
114
|
+
/** Sum of the bucket estimates. */
|
|
115
|
+
estimate: number;
|
|
116
|
+
/** `tokens - estimate` when the host's estimate is known, else `null`. */
|
|
117
|
+
drift: number | null;
|
|
118
|
+
/** The branch's compaction history. */
|
|
119
|
+
compaction: CompactionHistory;
|
|
120
|
+
/** The largest projected blocks, biggest first. */
|
|
121
|
+
largest: LargestBlock[];
|
|
122
|
+
}
|
|
@@ -83,6 +83,16 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
|
|
|
83
83
|
`worktree`; untracked files show as all-add, an unborn `HEAD` falls back to the
|
|
84
84
|
index, and a non-repository is a plain empty state. Read-only, capped, and
|
|
85
85
|
rendered with the shared diff colors.
|
|
86
|
+
- **Live status.** A page left open keeps up with the session: a `◉ live`
|
|
87
|
+
chip beside `read-only` says whether status is flowing (green), still
|
|
88
|
+
connecting (accent), stalled (amber, after three missed frames), or offline
|
|
89
|
+
(red); the branch chip, the `● N changes +A −D` chip, and the change markers in
|
|
90
|
+
the tree and the listing update in place; and the footer line reports `up 2m
|
|
91
|
+
14s · 37 requests`. The chip names its state and never counts, so nothing is
|
|
92
|
+
repainted between frames. One Server-Sent Events stream per page carries it, on
|
|
93
|
+
the same loopback server, so nothing extra is opened and nothing is written;
|
|
94
|
+
that stream is also how the server notices the last page closing its window,
|
|
95
|
+
after which it stops listening.
|
|
86
96
|
- **No dependencies.** `node:http` plus the inline sprite, CSS, and client
|
|
87
97
|
script.
|
|
88
98
|
|
|
@@ -103,7 +113,10 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
|
|
|
103
113
|
port; a busy port is reported, not silently changed.
|
|
104
114
|
- Starting while already running with the same root reprints the URL without
|
|
105
115
|
restarting; a different root (or a changed pinned port) restarts the server.
|
|
106
|
-
- The server is closed
|
|
116
|
+
- The server is closed when its last page has been gone for ten seconds, so
|
|
117
|
+
closing the browser window ends the session's browse server and `/browse`
|
|
118
|
+
starts it again on the same port. A reload or a new tab inside that grace keeps
|
|
119
|
+
it. `session_shutdown` closes it too, and `/browse stop` closes it at once.
|
|
107
120
|
|
|
108
121
|
### Status
|
|
109
122
|
|
|
@@ -114,6 +127,26 @@ beside `⎇ branch` and `⑂ worktree`; it
|
|
|
114
127
|
is cleared on stop and on `session_shutdown`. The chip is session-local: it
|
|
115
128
|
reflects only this session's server, with no cross-session sharing.
|
|
116
129
|
|
|
130
|
+
The page has its own live status, which is what a left-open tab reads. The
|
|
131
|
+
header chip starts hidden and is painted by the client from `/events`: `◉ live`
|
|
132
|
+
in the success colour while frames arrive, `◉ connecting` before the first
|
|
133
|
+
one, `◉ stale` in the warning colour when three intervals pass without a frame
|
|
134
|
+
on an open connection, and `◉ offline` in the danger colour once the stream
|
|
135
|
+
errors. The chip names its state rather than counting, so it is repainted only
|
|
136
|
+
when that state changes. The same frame carries the branch (or detached head), the
|
|
137
|
+
uncommitted-change totals and paths, the server's uptime, and the requests it
|
|
138
|
+
has answered, so the branch chip, the changes chip, the markers, and the footer
|
|
139
|
+
line are painted from one snapshot rather than four requests. Losing the stream
|
|
140
|
+
leaves everything the page last showed in place: it is read-only, so a stale
|
|
141
|
+
label is never a stale action. `"live": false` turns the whole surface off — no
|
|
142
|
+
chip, no stream, no `/events` route — and so does JavaScript off.
|
|
143
|
+
|
|
144
|
+
The stream is also how the server knows a page is still there: when the last one
|
|
145
|
+
leaves, the port is released after ten seconds and the chip is cleared, because
|
|
146
|
+
nothing is left to serve. That is why a page with JavaScript off — or with live
|
|
147
|
+
status off — never counts as a reader, and the server then waits for
|
|
148
|
+
`/browse stop` or the end of the session.
|
|
149
|
+
|
|
117
150
|
## Routes
|
|
118
151
|
|
|
119
152
|
| Route | Result |
|
|
@@ -126,6 +159,7 @@ reflects only this session's server, with no cross-session sharing.
|
|
|
126
159
|
| `/diff/<rel>[?mode=…]` | one file's unified diff |
|
|
127
160
|
| `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
|
|
128
161
|
| `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
|
|
162
|
+
| `/events` | Server-Sent Events stream of `status` frames for the open page |
|
|
129
163
|
| `/app.js` | the first-party client script |
|
|
130
164
|
| `/theme.js` | the pre-paint theme bootstrap |
|
|
131
165
|
| `/favicon.ico` | `204` (the icon is a data URI) |
|
|
@@ -150,7 +184,9 @@ document and the project document (project wins):
|
|
|
150
184
|
"maxIndexEntries": 20000, // files collected for quick open before truncation
|
|
151
185
|
"gitDiff": true, // offer the read-only diff view
|
|
152
186
|
"diffContext": 3, // unified diff context lines
|
|
153
|
-
"maxDiffLines": 4000
|
|
187
|
+
"maxDiffLines": 4000, // diff lines rendered before truncation
|
|
188
|
+
"live": true, // stream live status to open pages
|
|
189
|
+
"liveIntervalMs": 3000 // ms between status frames while a page is connected
|
|
154
190
|
}
|
|
155
191
|
```
|
|
156
192
|
|
|
@@ -185,6 +221,13 @@ document and the project document (project wins):
|
|
|
185
221
|
- **Highlighting safety.** The highlighter escapes every literal before it
|
|
186
222
|
wraps it in a token span, so a code file's content cannot inject markup or
|
|
187
223
|
attributes; only the fixed `tok-*` class names reach the page.
|
|
224
|
+
- **Event stream.** `/events` is a `GET` on the same loopback server, behind
|
|
225
|
+
the same method and `Host` checks, and it only reads: the frame is the branch,
|
|
226
|
+
the change paths, an uptime, and a request count, all of which the page it
|
|
227
|
+
feeds already renders. It carries no page policy (`text/event-stream` is not a
|
|
228
|
+
document), sends `Cache-Control: no-store`, and cannot be cached or replayed.
|
|
229
|
+
A tick runs only while a page is attached, and the timer is `unref`ed, so an
|
|
230
|
+
idle server does no work and a forgotten tab cannot hold the process open.
|
|
188
231
|
- **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
|
|
189
232
|
`maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
|
|
190
233
|
- **Git reads.** The header chip and the diff view run read-only git
|
|
@@ -200,6 +243,11 @@ document and the project document (project wins):
|
|
|
200
243
|
type renders exactly as before. State that the tokenizer does not carry
|
|
201
244
|
across lines (shell heredocs, interpolation inside a template literal) is
|
|
202
245
|
approximated rather than parsed.
|
|
246
|
+
- Live status updates what the page already contains: the header chips, the
|
|
247
|
+
markers on the rows it rendered, and the footer line. A file that appears on
|
|
248
|
+
disk joins the listing on the next reload, the `/diff` table and a file page's
|
|
249
|
+
`changes` action stay page-load snapshots, and the branch chip is only patched
|
|
250
|
+
when the page was served inside a repository.
|
|
203
251
|
- `/browse` has no auth; a malicious local page could probe the port. The `Host`
|
|
204
252
|
allowlist and the absence of CORS headers mitigate reads, and the server is
|
|
205
253
|
read-only.
|
|
@@ -221,7 +269,7 @@ document and the project document (project wins):
|
|
|
221
269
|
|---|---|
|
|
222
270
|
| Command | `/browse` registers a single command from the factory; no tools are exposed. |
|
|
223
271
|
| State | The running server, its root, and the session port live in a module-local closure; nothing is persisted and no session entries are written. |
|
|
224
|
-
| Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop
|
|
272
|
+
| Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop`, by `session_shutdown`, and once its last page has been gone for the idle grace, which also ends every attached status stream. |
|
|
225
273
|
|
|
226
274
|
## Design notes
|
|
227
275
|
|
|
@@ -232,7 +280,10 @@ document and the project document (project wins):
|
|
|
232
280
|
wins, otherwise a random port is drawn and cached after the first successful
|
|
233
281
|
bind, so later starts reuse it (a conflict is reported and the next `/browse`
|
|
234
282
|
rolls another). `/browse stop` and `session_shutdown` await the idempotent
|
|
235
|
-
`close()`; `session_shutdown` also clears the cached port.
|
|
283
|
+
`close()`; `session_shutdown` also clears the cached port. The page is the
|
|
284
|
+
server's only reader, so the channel reports the last page's departure after
|
|
285
|
+
`IDLE_STOP_MS` and the command closes the server and clears the chip — the
|
|
286
|
+
cached port survives, so the next `/browse` binds the same one.
|
|
236
287
|
- **Pure routing.** `handleRequest(context, method, rawUrl, headers)` validates
|
|
237
288
|
method and `Host`, parses the URL, matches a route, and runs every path
|
|
238
289
|
through `paths.ts`. It returns a plain `ServeResponse`, so routing is tested
|
|
@@ -309,6 +360,21 @@ document and the project document (project wins):
|
|
|
309
360
|
with the file view. `git diff --stat` supplies the chip totals and
|
|
310
361
|
`changeDirs` derives the directory markers; the changed lines are highlighted
|
|
311
362
|
per hunk by rebuilding the old and new sides for `lib/highlight`.
|
|
363
|
+
- **Live status.** `live.ts` builds one snapshot per tick — the git status,
|
|
364
|
+
the `head` change index, the server's uptime, and its request count — and
|
|
365
|
+
`frameOf` serializes it as one `status` event. `createLiveChannel` holds the
|
|
366
|
+
attached pages and starts the ticker on the first attach, so an idle server
|
|
367
|
+
does no work; it shares a single in-flight snapshot between pages, drops a
|
|
368
|
+
page whose write fails, and stops on the last detach and on `close()`, which
|
|
369
|
+
`server.ts` calls before closing the socket. The last detach also arms one
|
|
370
|
+
idle-stop timer — a reload re-attaches and cancels it — and `server.ts` turns
|
|
371
|
+
that into the extension's `close()` and a cleared chip. Both providers are already
|
|
372
|
+
TTL-cached, so a tick re-reads at most one `git status`, and every read
|
|
373
|
+
failure degrades to `null` rather than a failed frame. The event-stream
|
|
374
|
+
headers and the `retry:` preamble live in `lib/http` with the rest of the
|
|
375
|
+
loopback policy, shared with plan's review page. The client half is
|
|
376
|
+
`live-client.ts`: one `EventSource`, one snapshot paint (`textContent` only,
|
|
377
|
+
never markup), one staleness timeout armed per frame, and a `pagehide` cleanup.
|
|
312
378
|
- **Streaming responses.** The server streams `filePath` responses and
|
|
313
379
|
suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
|
|
314
380
|
- **Root confinement over feature breadth.** The root is deliberately confined
|
|
@@ -325,7 +391,7 @@ document and the project document (project wins):
|
|
|
325
391
|
| [`../../lib/escape.ts`](../../lib/escape.ts) | `escapeHtml`, the shared HTML escaping used by every renderer |
|
|
326
392
|
| [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
|
|
327
393
|
| [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
|
|
328
|
-
| `files.ts` | Classification (incl. Markdown),
|
|
394
|
+
| `files.ts` | Classification (incl. Markdown), text reads, and one page-level walk (`directoryView`) that returns a directory's listing and its sidebar tree |
|
|
329
395
|
| `diff.ts` | Pure unified-diff and porcelain-status parsing for the diff view |
|
|
330
396
|
| `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
|
|
331
397
|
| `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted; raw HTML through `raw-html.ts`) |
|
|
@@ -334,10 +400,13 @@ document and the project document (project wins):
|
|
|
334
400
|
| `git.ts` | Read-only branch/dirty provider for the header chip |
|
|
335
401
|
| `icons.ts` | Inline SVG sprite and the per-language mapping |
|
|
336
402
|
| `client.ts` | The `/app.js` source (tree toggle, filter, sort, wrap, copy, quick open) |
|
|
337
|
-
| `
|
|
403
|
+
| `live-client.ts` | The client's live status block: the event stream, the chips, the markers, and the footer line (interpolated into `client.ts`) |
|
|
404
|
+
| `live.ts` | The live status snapshot, its frame, and the fan-out channel that ticks it |
|
|
405
|
+
| `styles.ts` | The page-only `browse-*` stylesheet (the `bf-*` kit comes from `lib/web`) |
|
|
406
|
+
| `html.ts` | Page renderers: the shared shell, the two-pane layout, the listing, the cards, and the diff body |
|
|
338
407
|
| `router.ts` | Pure request handler: routes, the `/raw` sandbox policy, and the error page |
|
|
339
408
|
| `server.ts` | `node:http` server: this extension's handler over the shared bind, writer, and close |
|
|
340
|
-
| [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON headers, `ServeResponse`, the request-envelope helpers, bind/respond/close |
|
|
409
|
+
| [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON/event-stream headers, `ServeResponse`, the request-envelope helpers, bind/respond/close, and `openEventStream` |
|
|
341
410
|
| [`../../lib/browser.ts`](../../lib/browser.ts) | Cross-platform browser opener (shared with `plan`) |
|
|
342
411
|
|
|
343
412
|
## Testing
|
|
@@ -360,5 +429,11 @@ on first start, reused, and re-rolled after a conflict), the git status provider
|
|
|
360
429
|
and provider (line kinds, line numbers, status and rename parsing, mode
|
|
361
430
|
filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
|
|
362
431
|
cache), the diff renderers (highlighted changed lines, marker sets, and chip
|
|
363
|
-
totals),
|
|
432
|
+
totals), the live status feed (snapshot fields, degraded providers, the frame,
|
|
433
|
+
and the channel's ticker driven by an injected timer, plus the idle stop armed on
|
|
434
|
+
the last detach and cancelled by the next page or by `close()`), the `/events`
|
|
435
|
+
route and
|
|
436
|
+
its 404 when the channel is off, a real socket that reads a page's first frame
|
|
437
|
+
and the ticker's next one and sees the stream end when the server closes, the
|
|
438
|
+
live chrome a page renders when on and not when off, and the command
|
|
364
439
|
lifecycle with injected seams.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { keysDialogScript, SIDEBAR_COLLAPSE_MAX, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
|
|
15
|
+
import { LIVE_CLIENT_JS } from "./live-client.ts";
|
|
15
16
|
|
|
16
17
|
export const CLIENT_JS = `(function () {
|
|
17
18
|
"use strict";
|
|
@@ -716,5 +717,6 @@ export const CLIENT_JS = `(function () {
|
|
|
716
717
|
|
|
717
718
|
// The control starts in the state the rendered tree implies.
|
|
718
719
|
syncFoldButton();
|
|
719
|
-
|
|
720
|
+
|
|
721
|
+
${LIVE_CLIENT_JS}})();
|
|
720
722
|
`;
|
|
@@ -36,6 +36,10 @@ export interface ServeConfig {
|
|
|
36
36
|
diffContext: number;
|
|
37
37
|
/** Diff lines rendered before the view is truncated. */
|
|
38
38
|
maxDiffLines: number;
|
|
39
|
+
/** Push live status to open pages over `/events`. */
|
|
40
|
+
live: boolean;
|
|
41
|
+
/** How often a connected page is sent a status frame, in ms. */
|
|
42
|
+
liveIntervalMs: number;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export const DEFAULT_CONFIG: ServeConfig = {
|
|
@@ -52,6 +56,8 @@ export const DEFAULT_CONFIG: ServeConfig = {
|
|
|
52
56
|
gitDiff: true,
|
|
53
57
|
diffContext: 3,
|
|
54
58
|
maxDiffLines: 4000,
|
|
59
|
+
live: true,
|
|
60
|
+
liveIntervalMs: 3000,
|
|
55
61
|
};
|
|
56
62
|
|
|
57
63
|
/** Base of the per-session port range. */
|
|
@@ -60,6 +66,21 @@ export const PORT_BASE = 4780;
|
|
|
60
66
|
/** Number of ports in the per-session range. */
|
|
61
67
|
export const PORT_RANGE = 1000;
|
|
62
68
|
|
|
69
|
+
/** Fastest live-status cadence a page may be given, short enough to watch and
|
|
70
|
+
* long enough that the 2 s provider caches absorb most ticks. */
|
|
71
|
+
export const LIVE_INTERVAL_MIN = 250;
|
|
72
|
+
|
|
73
|
+
/** Slowest live-status cadence, so a page cannot look abandoned. */
|
|
74
|
+
export const LIVE_INTERVAL_MAX = 60_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How long the server keeps listening once the last page has gone. A reload or a
|
|
78
|
+
* navigation detaches the stream and re-attaches within a moment, so the grace has
|
|
79
|
+
* to outlast one; a closed window never re-attaches, and the port is released
|
|
80
|
+
* instead of listening for nobody until the session ends.
|
|
81
|
+
*/
|
|
82
|
+
export const IDLE_STOP_MS = 10_000;
|
|
83
|
+
|
|
63
84
|
/** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
|
|
64
85
|
export function randomPort(): number {
|
|
65
86
|
return PORT_BASE + randomInt(PORT_RANGE);
|
|
@@ -85,6 +106,8 @@ export function normalizeConfig(
|
|
|
85
106
|
gitDiff: asBoolean(raw.gitDiff, base.gitDiff),
|
|
86
107
|
diffContext: clampInteger(raw.diffContext, base.diffContext, 0, 100),
|
|
87
108
|
maxDiffLines: clampInteger(raw.maxDiffLines, base.maxDiffLines, 1, 1_000_000),
|
|
109
|
+
live: asBoolean(raw.live, base.live),
|
|
110
|
+
liveIntervalMs: clampInteger(raw.liveIntervalMs, base.liveIntervalMs, LIVE_INTERVAL_MIN, LIVE_INTERVAL_MAX),
|
|
88
111
|
};
|
|
89
112
|
}
|
|
90
113
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Dirent, Stats } from "node:fs";
|
|
9
9
|
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
10
10
|
import { basename, extname, join } from "node:path";
|
|
11
|
+
import { mapConcurrent } from "../../lib/pool.ts";
|
|
11
12
|
import { HttpError } from "./paths.ts";
|
|
12
13
|
|
|
13
14
|
export type EntryKind = "directory" | "text" | "image" | "binary";
|
|
@@ -188,63 +189,168 @@ function toAbs(root: string, rel: string): string {
|
|
|
188
189
|
return rel ? join(root, ...rel.split("/")) : root;
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
/**
|
|
192
|
-
export
|
|
192
|
+
/** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
|
|
193
|
+
export interface DirectoryEntryInfo {
|
|
194
|
+
name: string;
|
|
195
|
+
isDirectory(): boolean;
|
|
196
|
+
isSymbolicLink(): boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The `Stats` fields a listing reads. */
|
|
200
|
+
export interface DirectoryStats {
|
|
201
|
+
size: number;
|
|
202
|
+
mtimeMs: number;
|
|
203
|
+
isDirectory(): boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The three filesystem reads a listing makes.
|
|
208
|
+
*
|
|
209
|
+
* Injected so a test can observe them: the interesting property — a directory is
|
|
210
|
+
* read once per entry, not once per entry *and* once per row drawn — is a count,
|
|
211
|
+
* not a value, and a wall-clock assertion would be flaky on a loaded machine.
|
|
212
|
+
*/
|
|
213
|
+
export interface DirectoryFs {
|
|
214
|
+
readdir(path: string): Promise<DirectoryEntryInfo[]>;
|
|
215
|
+
lstat(path: string): Promise<DirectoryStats>;
|
|
216
|
+
stat(path: string): Promise<DirectoryStats>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Production reads; a symlink's size and kind come from the target. */
|
|
220
|
+
const NODE_DIRECTORY_FS: DirectoryFs = {
|
|
221
|
+
readdir: (path) => readdir(path, { withFileTypes: true }),
|
|
222
|
+
lstat: (path) => lstat(path),
|
|
223
|
+
stat: (path) => stat(path),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** Reads in flight per listing: enough to hide latency, bounded against a burst. */
|
|
227
|
+
const LISTING_CONCURRENCY = 32;
|
|
228
|
+
|
|
229
|
+
/** A row while it is still missing its metadata. */
|
|
230
|
+
interface Row {
|
|
231
|
+
name: string;
|
|
232
|
+
rel: string;
|
|
233
|
+
isDir: boolean;
|
|
234
|
+
isSymlink: boolean;
|
|
235
|
+
broken: boolean;
|
|
236
|
+
size: number;
|
|
237
|
+
mtimeMs: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolve one symlink: the link's own stat, then the target's.
|
|
242
|
+
*
|
|
243
|
+
* A symlink's directory-ness is the target's, so this cannot wait until after
|
|
244
|
+
* the sort; it is also the only entry whose classification needs a read.
|
|
245
|
+
*/
|
|
246
|
+
async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
|
|
247
|
+
const abs = join(absDir, row.name);
|
|
248
|
+
try {
|
|
249
|
+
const info = await fs.lstat(abs);
|
|
250
|
+
row.size = info.size;
|
|
251
|
+
row.mtimeMs = info.mtimeMs;
|
|
252
|
+
} catch {
|
|
253
|
+
// Vanished between readdir and lstat: the target read decides below.
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const target = await fs.stat(abs);
|
|
257
|
+
row.isDir = target.isDirectory();
|
|
258
|
+
row.size = target.size;
|
|
259
|
+
row.mtimeMs = target.mtimeMs;
|
|
260
|
+
} catch {
|
|
261
|
+
row.broken = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* List a directory, sorted (directories first, then case-insensitive name).
|
|
267
|
+
*
|
|
268
|
+
* A listing is latency per entry, not work per entry, so it is read in three
|
|
269
|
+
* passes instead of one entry at a time:
|
|
270
|
+
*
|
|
271
|
+
* 1. `readdir`, whose dirents already say whether each name is a directory;
|
|
272
|
+
* 2. the symlinks only, resolved through the pool, because their kind is the
|
|
273
|
+
* target's and the sort needs it;
|
|
274
|
+
* 3. the kept rows only, `stat`ed through the pool — so a directory of three
|
|
275
|
+
* thousand names pays for the two thousand it draws, not for every name, and
|
|
276
|
+
* pays them concurrently instead of in turn.
|
|
277
|
+
*
|
|
278
|
+
* `total` is still the directory's full entry count and `truncated` its `> limit`
|
|
279
|
+
* flip, so the page can say how many were dropped. Ties keep `readdir` order:
|
|
280
|
+
* `sort` is stable and the rows arrive unsorted.
|
|
281
|
+
*/
|
|
282
|
+
export async function listDirectory(
|
|
283
|
+
root: string,
|
|
284
|
+
relDir: string,
|
|
285
|
+
limit: number,
|
|
286
|
+
fs: DirectoryFs = NODE_DIRECTORY_FS,
|
|
287
|
+
): Promise<Listing> {
|
|
193
288
|
const absDir = toAbs(root, relDir);
|
|
194
|
-
let dirents:
|
|
289
|
+
let dirents: DirectoryEntryInfo[];
|
|
195
290
|
try {
|
|
196
|
-
dirents = await readdir(absDir
|
|
291
|
+
dirents = await fs.readdir(absDir);
|
|
197
292
|
} catch {
|
|
198
293
|
throw new HttpError(404, "That folder could not be read.");
|
|
199
294
|
}
|
|
200
295
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const isSymlink = dirent.isSymbolicLink();
|
|
205
|
-
let isDir = dirent.isDirectory();
|
|
206
|
-
let broken = false;
|
|
207
|
-
let size = 0;
|
|
208
|
-
let mtimeMs = 0;
|
|
209
|
-
try {
|
|
210
|
-
const info = isSymlink ? await lstat(abs) : await stat(abs);
|
|
211
|
-
size = info.size;
|
|
212
|
-
mtimeMs = info.mtimeMs;
|
|
213
|
-
} catch {
|
|
214
|
-
// Directory entry vanished between readdir and stat.
|
|
215
|
-
}
|
|
216
|
-
if (isSymlink) {
|
|
217
|
-
try {
|
|
218
|
-
const target = await stat(abs);
|
|
219
|
-
isDir = target.isDirectory();
|
|
220
|
-
size = target.size;
|
|
221
|
-
mtimeMs = target.mtimeMs;
|
|
222
|
-
} catch {
|
|
223
|
-
broken = true;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
const kind: EntryKind = isDir ? "directory" : classifyByName(dirent.name);
|
|
227
|
-
entries.push({
|
|
296
|
+
const links: Row[] = [];
|
|
297
|
+
const rows: Row[] = dirents.map((dirent) => {
|
|
298
|
+
const row: Row = {
|
|
228
299
|
name: dirent.name,
|
|
229
300
|
rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
|
|
230
|
-
isDir,
|
|
231
|
-
isSymlink,
|
|
232
|
-
broken,
|
|
233
|
-
size,
|
|
234
|
-
mtimeMs,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
301
|
+
isDir: dirent.isDirectory(),
|
|
302
|
+
isSymlink: dirent.isSymbolicLink(),
|
|
303
|
+
broken: false,
|
|
304
|
+
size: 0,
|
|
305
|
+
mtimeMs: 0,
|
|
306
|
+
};
|
|
307
|
+
if (row.isSymlink) links.push(row);
|
|
308
|
+
return row;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
|
|
239
312
|
|
|
240
|
-
|
|
313
|
+
// Directories first, then case-insensitive name. The sort runs before the cap,
|
|
314
|
+
// so the rows that survive it are the rows that would have been drawn.
|
|
315
|
+
rows.sort((a, b) => {
|
|
241
316
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
242
317
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
243
318
|
});
|
|
244
319
|
|
|
245
|
-
const total =
|
|
320
|
+
const total = rows.length;
|
|
246
321
|
const truncated = total > limit;
|
|
247
|
-
|
|
322
|
+
const kept = truncated ? rows.slice(0, limit) : rows;
|
|
323
|
+
|
|
324
|
+
await mapConcurrent(
|
|
325
|
+
kept.filter((row) => !row.isSymlink),
|
|
326
|
+
LISTING_CONCURRENCY,
|
|
327
|
+
async (row) => {
|
|
328
|
+
try {
|
|
329
|
+
const info = await fs.stat(join(absDir, row.name));
|
|
330
|
+
row.size = info.size;
|
|
331
|
+
row.mtimeMs = info.mtimeMs;
|
|
332
|
+
} catch {
|
|
333
|
+
// Directory entry vanished between readdir and stat.
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const entries: DirEntry[] = kept.map((row) => {
|
|
339
|
+
const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
|
|
340
|
+
return {
|
|
341
|
+
name: row.name,
|
|
342
|
+
rel: row.rel,
|
|
343
|
+
isDir: row.isDir,
|
|
344
|
+
isSymlink: row.isSymlink,
|
|
345
|
+
broken: row.broken,
|
|
346
|
+
size: row.size,
|
|
347
|
+
mtimeMs: row.mtimeMs,
|
|
348
|
+
kind,
|
|
349
|
+
isImage: !row.isDir && kind === "image",
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return { entries, total, truncated };
|
|
248
354
|
}
|
|
249
355
|
|
|
250
356
|
/** Read a file into a renderable view, honouring the size and line caps. */
|
|
@@ -305,12 +411,37 @@ export async function buildTree(root: string, currentRel: string, limit: number)
|
|
|
305
411
|
return buildNode(root, "", currentRel, basename(root) || root, limit);
|
|
306
412
|
}
|
|
307
413
|
|
|
414
|
+
/** A directory page's two views of one directory. */
|
|
415
|
+
export interface DirectoryView {
|
|
416
|
+
/** The current directory's own rows, for the page's listing. */
|
|
417
|
+
listing: Listing;
|
|
418
|
+
/** The sidebar tree, with the current path expanded. */
|
|
419
|
+
tree: TreeNode;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* One walk for a directory page: the listing it draws, and the tree that shows
|
|
424
|
+
* where it sits.
|
|
425
|
+
*
|
|
426
|
+
* `listDirectory` is what decides whether the directory can be read at all, so it
|
|
427
|
+
* runs first and its 404 is the page's error. The walk then reuses that listing
|
|
428
|
+
* for the same directory instead of reading it a second time; the ancestors are
|
|
429
|
+
* still read once each, which is what the tree needs.
|
|
430
|
+
*/
|
|
431
|
+
export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
|
|
432
|
+
const listing = await listDirectory(root, currentRel, limit);
|
|
433
|
+
const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
|
|
434
|
+
return { listing, tree };
|
|
435
|
+
}
|
|
436
|
+
|
|
308
437
|
async function buildNode(
|
|
309
438
|
root: string,
|
|
310
439
|
relDir: string,
|
|
311
440
|
currentRel: string,
|
|
312
441
|
label: string,
|
|
313
442
|
limit: number,
|
|
443
|
+
/** A listing the caller already read for this directory, so it is not re-read. */
|
|
444
|
+
known?: { rel: string; listing: Listing },
|
|
314
445
|
): Promise<TreeNode> {
|
|
315
446
|
const node: TreeNode = {
|
|
316
447
|
name: label,
|
|
@@ -322,10 +453,14 @@ async function buildNode(
|
|
|
322
453
|
children: [],
|
|
323
454
|
};
|
|
324
455
|
let listing: Listing;
|
|
325
|
-
|
|
326
|
-
listing =
|
|
327
|
-
}
|
|
328
|
-
|
|
456
|
+
if (known && known.rel === relDir) {
|
|
457
|
+
listing = known.listing;
|
|
458
|
+
} else {
|
|
459
|
+
try {
|
|
460
|
+
listing = await listDirectory(root, relDir, limit);
|
|
461
|
+
} catch {
|
|
462
|
+
return node;
|
|
463
|
+
}
|
|
329
464
|
}
|
|
330
465
|
for (const entry of listing.entries) {
|
|
331
466
|
const child: TreeNode = {
|
|
@@ -340,7 +475,7 @@ async function buildNode(
|
|
|
340
475
|
if (entry.isDir) {
|
|
341
476
|
const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
|
|
342
477
|
if (onPath) {
|
|
343
|
-
child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit)).children;
|
|
478
|
+
child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
|
|
344
479
|
}
|
|
345
480
|
}
|
|
346
481
|
node.children.push(child);
|