@gavin-hu/my-pi-agent 0.15.0 → 0.15.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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Live status for the browse pages.
3
+ *
4
+ * A page that stays open holds one event stream; this module builds the snapshot
5
+ * it is sent, the frame that carries it, and the channel that fans it out on a
6
+ * tick. The git and diff providers already memoize, so a tick re-reads at most
7
+ * one `git status`, and every read failure degrades to `null`: a status feed
8
+ * must never be able to fail a page or the server that serves it.
9
+ *
10
+ * The channel opens no socket of its own — the caller hands it a `write`/`end`
11
+ * pair — so the whole feed is tested without one.
12
+ */
13
+
14
+ import { changeDirs } from "./diff.ts";
15
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
16
+
17
+ /**
18
+ * The changed-path index the pages render from: the totals the changes chip
19
+ * prints and the paths the tree and listing mark.
20
+ */
21
+ export interface ChangeIndex {
22
+ /** Changed paths in the scope. */
23
+ count: number;
24
+ /** Added lines in the scope. */
25
+ insertions: number;
26
+ /** Removed lines in the scope. */
27
+ deletions: number;
28
+ /** Changed files, root-relative. */
29
+ files: ReadonlySet<string>;
30
+ /** Directories that contain a changed file. */
31
+ dirs: ReadonlySet<string>;
32
+ }
33
+
34
+ /** The marker/totals index a change list yields. */
35
+ export function changeIndexOf(list: DiffList): ChangeIndex {
36
+ return {
37
+ count: list.files.length,
38
+ insertions: list.insertions,
39
+ deletions: list.deletions,
40
+ files: new Set(list.files.map((file) => file.rel)),
41
+ dirs: changeDirs(list.files),
42
+ };
43
+ }
44
+
45
+ /** A change index as it goes over the wire: sets become ordered lists. */
46
+ export interface LiveChanges {
47
+ count: number;
48
+ insertions: number;
49
+ deletions: number;
50
+ files: string[];
51
+ dirs: string[];
52
+ }
53
+
54
+ /** One status frame's payload. */
55
+ export interface LiveSnapshot {
56
+ /** Server wall clock when the snapshot was taken, in ms. */
57
+ at: number;
58
+ /** How long the server has been listening, in ms. */
59
+ uptimeMs: number;
60
+ /** Requests the server has answered so far, this one included. */
61
+ requests: number;
62
+ /** Branch or detached head, or `null` outside a repository. */
63
+ git: GitStatus | null;
64
+ /** Uncommitted changes against `HEAD`, or `null` when unavailable. */
65
+ changes: LiveChanges | null;
66
+ }
67
+
68
+ /** The snapshot a streamed frame is built from. */
69
+ export interface LiveStatusSource {
70
+ snapshot(): Promise<LiveSnapshot>;
71
+ }
72
+
73
+ export interface LiveStatusOptions {
74
+ /** Read-only git context; omitted when the page has none. */
75
+ git?: GitStatusProvider;
76
+ /** Read-only diff provider; omitted when the diff view is off. */
77
+ diff?: GitDiffProvider;
78
+ /** When the server started listening, in ms. */
79
+ startedAt: number;
80
+ /** Requests answered so far, read per snapshot. */
81
+ requests: () => number;
82
+ /** Wall-clock seam; defaults to the real clock. */
83
+ now?: () => number;
84
+ }
85
+
86
+ /** Read a provider, turning a rejection or an absent value into `null`. */
87
+ async function settled<T>(read: () => Promise<T | undefined>): Promise<T | null> {
88
+ try {
89
+ return (await read()) ?? null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build the status source for one server. `snapshot` never rejects: a
97
+ * non-repository, a failed git call, or a timeout simply leaves that field
98
+ * `null`, and the page keeps whatever it last showed.
99
+ */
100
+ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
101
+ const now = options.now ?? (() => Date.now());
102
+ const diff = options.diff;
103
+ return {
104
+ async snapshot() {
105
+ const at = now();
106
+ const git = options.git ? await settled(options.git) : null;
107
+ const list = diff ? await settled(() => diff.list("head")) : null;
108
+ const index = list ? changeIndexOf(list) : null;
109
+ return {
110
+ at,
111
+ uptimeMs: Math.max(0, at - options.startedAt),
112
+ requests: options.requests(),
113
+ git,
114
+ changes: index
115
+ ? {
116
+ count: index.count,
117
+ insertions: index.insertions,
118
+ deletions: index.deletions,
119
+ files: [...index.files].sort(),
120
+ dirs: [...index.dirs].sort(),
121
+ }
122
+ : null,
123
+ };
124
+ },
125
+ };
126
+ }
127
+
128
+ /** One server-sent event carrying a snapshot. */
129
+ export function frameOf(snapshot: LiveSnapshot): string {
130
+ return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
131
+ }
132
+
133
+ /** A connected page: the two things a server can do to its stream. */
134
+ export interface LiveClient {
135
+ /** Write one frame. */
136
+ write(frame: string): void;
137
+ /** End the stream. */
138
+ end(): void;
139
+ }
140
+
141
+ /** Starts a repeating tick and returns its cancel. */
142
+ export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
143
+
144
+ /** The default ticker: a repeating timer that cannot hold the process open. */
145
+ const defaultTimer: LiveTimer = (tick, intervalMs) => {
146
+ const handle = setInterval(tick, intervalMs);
147
+ (handle as { unref?: () => void }).unref?.();
148
+ return () => clearInterval(handle);
149
+ };
150
+
151
+ export interface LiveChannelOptions {
152
+ /** What each frame carries. */
153
+ status: LiveStatusSource;
154
+ /** How far apart the frames are, in ms. */
155
+ intervalMs: number;
156
+ /** Timer seam; defaults to an unref'd interval. */
157
+ timer?: LiveTimer;
158
+ }
159
+
160
+ /** The fan-out for one server: pages attach, ticks publish, close ends them. */
161
+ export interface LiveChannel {
162
+ /** Add a connected page, send it the current status, and return its detach. */
163
+ attach(client: LiveClient): () => void;
164
+ /** End every connected page and stop the ticker. */
165
+ close(): void;
166
+ }
167
+
168
+ /**
169
+ * Build a channel for one server. The ticker runs only while at least one page
170
+ * is attached, so an idle server does no status work at all.
171
+ */
172
+ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
173
+ const timer = options.timer ?? defaultTimer;
174
+ const clients = new Set<LiveClient>();
175
+ let cancel: (() => void) | undefined;
176
+ let inFlight = false;
177
+
178
+ const stop = (): void => {
179
+ cancel?.();
180
+ cancel = undefined;
181
+ };
182
+
183
+ const send = (frame: string): void => {
184
+ for (const client of clients) {
185
+ try {
186
+ client.write(frame);
187
+ } catch {
188
+ clients.delete(client);
189
+ }
190
+ }
191
+ if (clients.size === 0) stop();
192
+ };
193
+
194
+ // One snapshot serves every attached page, so a tick is one git read and one
195
+ // serialization however many tabs are open.
196
+ const publish = async (): Promise<void> => {
197
+ if (inFlight) return;
198
+ inFlight = true;
199
+ try {
200
+ send(frameOf(await options.status.snapshot()));
201
+ } finally {
202
+ inFlight = false;
203
+ }
204
+ };
205
+
206
+ return {
207
+ attach(client) {
208
+ clients.add(client);
209
+ cancel ??= timer(() => void publish(), options.intervalMs);
210
+ void publish();
211
+ return () => {
212
+ if (!clients.delete(client)) return;
213
+ if (clients.size === 0) stop();
214
+ };
215
+ },
216
+ close() {
217
+ stop();
218
+ for (const client of clients) client.end();
219
+ clients.clear();
220
+ },
221
+ };
222
+ }
@@ -10,6 +10,7 @@
10
10
  import type { Stats } from "node:fs";
11
11
  import { stat } from "node:fs/promises";
12
12
  import {
13
+ eventHeaders,
13
14
  htmlResponse,
14
15
  isLocalHostHeader,
15
16
  jsonError,
@@ -22,12 +23,14 @@ import {
22
23
  import { THEME_JS } from "../../lib/web/index.ts";
23
24
  import { CLIENT_JS } from "./client.ts";
24
25
  import type { ServeConfig } from "./config.ts";
25
- import { changeDirs, isDiffMode, type DiffMode } from "./diff.ts";
26
+ import { isDiffMode, type DiffMode } from "./diff.ts";
26
27
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
28
+ import { changeIndexOf, type LiveStatusSource } from "./live.ts";
27
29
  import {
28
30
  buildFileIndex,
29
31
  buildTree,
30
32
  contentTypeFor,
33
+ directoryView,
31
34
  isImageName,
32
35
  listDirectory,
33
36
  readFileView,
@@ -50,6 +53,8 @@ export interface ServeContext {
50
53
  diff?: GitDiffProvider;
51
54
  /** Cached quick-open file index; built on demand when omitted. */
52
55
  index?: FileIndexProvider;
56
+ /** Snapshot source for the live status channel; omitted when live status is off. */
57
+ live?: LiveStatusSource;
53
58
  }
54
59
 
55
60
  /** Most palette rows the client will render for one query. */
@@ -93,15 +98,21 @@ async function diffFor(ctx: ServeContext, mode: DiffMode) {
93
98
 
94
99
  /** The marker/totals index derived from a change list. */
95
100
  function summaryOf(list: DiffList): ChangeSummary {
101
+ const index = changeIndexOf(list);
96
102
  return {
97
- count: list.files.length,
98
- insertions: list.insertions,
99
- deletions: list.deletions,
100
- files: new Set(list.files.map((file) => file.rel)),
101
- dirs: changeDirs(list.files),
103
+ count: index.count,
104
+ insertions: index.insertions,
105
+ deletions: index.deletions,
106
+ files: index.files,
107
+ dirs: index.dirs,
102
108
  };
103
109
  }
104
110
 
111
+ /** The live chrome a page renders, or `undefined` when live status is off. */
112
+ function liveOf(ctx: ServeContext): { intervalMs: number } | undefined {
113
+ return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs } : undefined;
114
+ }
115
+
105
116
  /** The `head` change summary, for the header chip, tree markers, and file action. */
106
117
  async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
107
118
  const list = await diffFor(ctx, "head");
@@ -135,6 +146,7 @@ async function errorPage(
135
146
  message,
136
147
  tree,
137
148
  git: await gitFor(ctx),
149
+ live: liveOf(ctx),
138
150
  });
139
151
  return htmlResponse(status, body, extra);
140
152
  }
@@ -158,8 +170,7 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
158
170
  }
159
171
 
160
172
  async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeResponse> {
161
- const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
162
- const tree = await treeFor(ctx, rel);
173
+ const { listing, tree } = await directoryView(ctx.root, rel, ctx.config.maxDirEntries);
163
174
  const git = await gitFor(ctx);
164
175
  const changes = await changed(ctx);
165
176
  return htmlResponse(
@@ -173,6 +184,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
173
184
  maxThumbBytes: ctx.config.maxThumbBytes,
174
185
  git,
175
186
  summary: changes,
187
+ live: liveOf(ctx),
176
188
  }),
177
189
  );
178
190
  }
@@ -195,6 +207,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
195
207
  markdownHtml: ctx.config.markdownHtml,
196
208
  changed: changes?.files.has(rel),
197
209
  summary: changes,
210
+ live: liveOf(ctx),
198
211
  }),
199
212
  );
200
213
  }
@@ -215,6 +228,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
215
228
  tree,
216
229
  git,
217
230
  summary: list ? summaryOf(list) : undefined,
231
+ live: liveOf(ctx),
218
232
  }),
219
233
  );
220
234
  }
@@ -246,6 +260,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
246
260
  tree,
247
261
  git,
248
262
  summary: changes,
263
+ live: liveOf(ctx),
249
264
  }),
250
265
  );
251
266
  }
@@ -304,6 +319,15 @@ async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse
304
319
  return jsonResponse(200, { query, total: index.files.length, truncated: index.truncated, results });
305
320
  }
306
321
 
322
+ /**
323
+ * The live status stream: a marker response the server in `server.ts` keeps open
324
+ * and feeds from the same source.
325
+ */
326
+ function liveEvents(ctx: ServeContext): ServeResponse {
327
+ if (!ctx.config.live || !ctx.live) throw new HttpError(404, "Live status is not available.");
328
+ return { status: 200, headers: eventHeaders(), stream: true };
329
+ }
330
+
307
331
  /**
308
332
  * Handle one request. `method` and `headers` are the raw HTTP values; `rawUrl`
309
333
  * is the request target (path plus optional query).
@@ -332,6 +356,7 @@ export async function handleRequest(
332
356
  if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
333
357
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
334
358
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
359
+ if (pathname === "/events") return liveEvents(ctx);
335
360
  const rawMode = search.get("mode");
336
361
  if (rawMode !== null && !isDiffMode(rawMode)) return errorPage(ctx, 400, "Unknown diff mode.");
337
362
  const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
@@ -2,21 +2,30 @@
2
2
  * The HTTP server for the browse extension.
3
3
  *
4
4
  * Wraps `node:http` around the pure `handleRequest`: it resolves the root once,
5
- * binds `127.0.0.1`, streams `/raw` responses from disk, and exposes an
6
- * idempotent `close()`. The request handler is injectable so tests can avoid a
7
- * socket when they only need routing.
5
+ * binds `127.0.0.1`, streams `/raw` responses from disk, feeds the live status
6
+ * channel, and exposes an idempotent `close()`. The request handler is injectable
7
+ * so tests can avoid a socket when they only need routing.
8
8
  *
9
- * The bind, the response writer, and the close come from `lib/http`, which `plan`
10
- * shares; what stays here is this server's own request handler and the catch that
11
- * turns a thrown handler into a plain-text 500.
9
+ * The bind, the response writer, the event-stream head, and the close come from
10
+ * `lib/http`, which `plan` shares; what stays here is this server's own request
11
+ * handler, its request count, and the catch that turns a thrown handler into a
12
+ * plain-text 500.
12
13
  */
13
14
 
14
15
  import { realpathSync } from "node:fs";
15
16
  import { createServer } from "node:http";
16
- import { bindLoopback, createCloser, respond, textResponse, type ServeResponse } from "../../lib/http/index.ts";
17
+ import {
18
+ bindLoopback,
19
+ createCloser,
20
+ openEventStream,
21
+ respond,
22
+ textResponse,
23
+ type ServeResponse,
24
+ } from "../../lib/http/index.ts";
17
25
  import type { ServeConfig } from "./config.ts";
18
26
  import { createFileIndex } from "./files.ts";
19
27
  import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
28
+ import { createLiveChannel, createLiveStatus } from "./live.ts";
20
29
  import { handleRequest, type ServeContext } from "./router.ts";
21
30
 
22
31
  export interface FileServer {
@@ -44,16 +53,31 @@ export interface FileServerOptions {
44
53
  /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
45
54
  export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
46
55
  const root = realpathSync.native(options.root);
56
+ const { config } = options;
57
+ // The status feed's two server-owned facts: how long it has been up, and how
58
+ // many requests it has answered. Both are read per frame, never cached.
59
+ let requests = 0;
60
+ const status = config.live
61
+ ? createLiveStatus({
62
+ git: options.git,
63
+ diff: config.gitDiff ? options.diff : undefined,
64
+ startedAt: Date.now(),
65
+ requests: () => requests,
66
+ })
67
+ : undefined;
68
+ const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : undefined;
47
69
  const context: ServeContext = {
48
70
  root,
49
- config: options.config,
71
+ config,
50
72
  git: options.git,
51
73
  diff: options.diff,
52
- index: createFileIndex(root, options.config.maxIndexEntries),
74
+ index: createFileIndex(root, config.maxIndexEntries),
75
+ live: status,
53
76
  };
54
77
  const handler = options.handler ?? handleRequest;
55
78
 
56
79
  const server = createServer((request, response) => {
80
+ requests += 1;
57
81
  const answer = async (): Promise<ServeResponse> => {
58
82
  try {
59
83
  return await handler(context, request.method ?? "GET", request.url ?? "/", request.headers);
@@ -61,9 +85,39 @@ export async function createFileServer(options: FileServerOptions): Promise<File
61
85
  return textResponse(500, "Internal error");
62
86
  }
63
87
  };
64
- void answer().then((result) => respond(request, response, result));
88
+ void answer().then((result) => {
89
+ // A HEAD of the stream is answered as a plain response: there is no body to
90
+ // hold open, and the client would otherwise wait on frames it asked not to get.
91
+ if (!result.stream || !live || (request.method ?? "GET").toUpperCase() === "HEAD") {
92
+ respond(request, response, result);
93
+ return;
94
+ }
95
+ openEventStream(response, result);
96
+ const detach = live.attach({
97
+ write: (frame) => {
98
+ response.write(frame);
99
+ },
100
+ end: () => {
101
+ if (!response.writableEnded) response.end();
102
+ },
103
+ });
104
+ request.on("close", () => {
105
+ detach();
106
+ if (!response.writableEnded) response.end();
107
+ });
108
+ });
65
109
  });
66
110
 
67
- const port = await bindLoopback(server, options.config.port);
68
- return { url: `http://127.0.0.1:${port}/`, port, root, close: createCloser(server) };
111
+ const port = await bindLoopback(server, config.port);
112
+ const closeServer = createCloser(server);
113
+ return {
114
+ url: `http://127.0.0.1:${port}/`,
115
+ port,
116
+ root,
117
+ close: async () => {
118
+ // End the streams first, so a connected page does not hold the close open.
119
+ live?.close();
120
+ await closeServer();
121
+ },
122
+ };
69
123
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The browse pages' stylesheet: the shared Bytefly shell and components, then
3
+ * the page-only `browse-*` rules layered on top.
4
+ *
5
+ * Kept apart from `html.ts` so the renderers stay a renderer and the CSS stays
6
+ * findable as one block; the `bf-*` kit itself lives in `lib/web`.
7
+ */
8
+
9
+ import {
10
+ SIDEBAR_COLLAPSE_MAX,
11
+ WEB_BASE_CSS,
12
+ WEB_COMPONENTS_CSS,
13
+ WEB_LAYOUT_CSS,
14
+ WEB_THEME_CSS,
15
+ } from "../../lib/web/index.ts";
16
+
17
+ /** The shared shell, components, and the page-only `browse-*` rules. */
18
+ export const BROWSE_CSS = `${WEB_BASE_CSS}
19
+ ${WEB_THEME_CSS}
20
+ ${WEB_LAYOUT_CSS}
21
+ ${WEB_COMPONENTS_CSS}
22
+ ${WEB_THEME_CSS}
23
+ ${WEB_LAYOUT_CSS}
24
+ ${WEB_COMPONENTS_CSS}
25
+ .browse-side{padding:var(--space-3)}
26
+ .browse-root{flex:1 1 auto;direction:rtl;text-align:left}
27
+ .browse-git{font-family:var(--font-mono)}
28
+ .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}
29
+ .browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
30
+ .browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
31
+ .browse-side__filters{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)}
32
+ .browse-side__filters .bf-field{flex:1 1 auto;min-width:0}
33
+ .browse-side__filters .bf-btn{flex:0 0 auto;white-space:nowrap}
34
+ .browse-cat{width:16px;height:16px;flex:0 0 16px;color:var(--accent)}
35
+ .browse-cat.doc{color:var(--muted)}.browse-cat.img{color:var(--success)}.browse-cat.cfg{color:var(--warn)}.browse-cat.arc{color:var(--warn)}
36
+ .browse-heading{margin:0 0 var(--space-3);font-size:var(--fs-lg)}
37
+ .browse-listing th.bf-table__size,.browse-listing th.bf-table__when{text-align:right}
38
+ .browse-listing th.bf-table__size{width:96px}
39
+ .browse-listing th.bf-table__when{width:140px}
40
+ .browse-dotfile .bf-table__name a{opacity:.72}
41
+ .browse-broken{color:var(--muted);text-decoration:line-through}
42
+ .browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
43
+ .browse-when{display:none}
44
+ body.palette-open{overflow:hidden}
45
+ .browse-diff{font-family:var(--font-mono);gap:5px}
46
+ .browse-diff:hover{color:var(--accent);text-decoration:none}
47
+ .browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
48
+ .browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
49
+ /* Live status: the connection chip keeps its own border and padding so a tone
50
+ swap only changes the colour, and the footer line reads as one muted clause. */
51
+ .browse-live{font-family:var(--font-mono);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
52
+ .browse-live__glyph{font-size:9px;line-height:1}
53
+ .browse-live-line{color:var(--muted);font-variant-numeric:tabular-nums}
54
+ .browse-live-line::after{content:" · "}
55
+ .browse-diff-totals{font-family:var(--font-mono)}
56
+ .browse-diff-add{color:var(--success)}
57
+ .browse-diff-del{color:var(--danger)}
58
+ .browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
59
+ .browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
60
+ .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}
61
+ .browse-diff th.browse-diff-status{width:72px}
62
+ .browse-diff th.browse-diff-word{width:130px}
63
+ .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)}
64
+ .browse-diff-badge--M{color:var(--warn);border-color:currentColor}
65
+ .browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
66
+ .browse-diff-badge--D{color:var(--danger);border-color:currentColor}
67
+ .browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
68
+ .browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
69
+ .browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
70
+ .browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
71
+ .browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
72
+ .bf-diff-line--add{background:var(--success-soft)}
73
+ .bf-diff-line--add .browse-diff-sign{color:var(--success)}
74
+ .bf-diff-line--del{background:var(--danger-soft)}
75
+ .bf-diff-line--del .browse-diff-sign{color:var(--danger)}
76
+ .bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
77
+ .bf-diff-line--meta{color:var(--muted)}
78
+ .bf-diff-line--note{color:var(--muted);font-style:italic}
79
+ /* A hunk header pins to the top of the diff viewport while its body scrolls.
80
+ Each hunk is a section so the pinned header is bounded by its own body and
81
+ cannot stack on the next one. */
82
+ .bf-diff-hunk{display:block}
83
+ .bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
84
+ /* Reading progress, pinned to the sticky header's bottom edge. */
85
+ .browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
86
+ .browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
87
+ /* File and diff pages: a measured reading column inside the card. */
88
+ /* The Markdown body fills the card, so the scroll box is the card's width: its
89
+ scrollbar sits at the card edge. The measure lives on the blocks themselves:
90
+ on .bf-md it would be a flex item with auto inline margins, which is
91
+ shrink-to-fit, so a short document collapsed to its widest line. */
92
+ .browse-measure{--browse-measure:1012px}
93
+ .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)}
94
+ .browse-measure .bf-md>*{max-width:var(--browse-measure);margin-inline:auto}
95
+ /* Tables start at the prose column and take only the gutter to its right, so a
96
+ wide window widens them without a dead band on their left. Code stays inside
97
+ the measure with the rest of the prose. */
98
+ .browse-measure .bf-md>.md-table-frame{max-width:none;padding-left:var(--browse-gutter)}
99
+ /* A table wider than the pane scrolls, but an overlay scrollbar is invisible
100
+ until it moves; a gradient on the frame marks the columns still out of view.
101
+ It has to be the frame: the scroller clips its own content, so a pseudo-element
102
+ inside it would scroll away with the table. */
103
+ .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)}
104
+ .bf-card__meta{font-variant-numeric:tabular-nums}
105
+ .browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
106
+ .browse-diff-summary .browse-diff-add{color:var(--success)}
107
+ .browse-diff-summary .browse-diff-del{color:var(--danger)}
108
+ .browse-hunks{color:var(--muted)}
109
+ /* Wide screens: pin quick open and the filter, and scroll the tree on its own
110
+ box. The sidebar is a details element, whose content sits in an anonymous
111
+ wrapper with no definite height, so the panel fills the sidebar by absolute
112
+ positioning; below the sidebar breakpoint the disclosure keeps normal page
113
+ scroll. */
114
+ @media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
115
+ .browse-sidebar{position:relative;overflow:hidden}
116
+ .browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
117
+ .browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
118
+ }
119
+ @media (max-width:820px){
120
+ .browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
121
+ }
122
+ @media (max-width:520px){
123
+ .bf-toolbar .bf-theme-toggle{margin-left:auto}
124
+ }`;
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
+ eventHeaders,
16
17
  isLocalHostHeader,
17
18
  jsonError,
18
19
  jsonHeaders,
@@ -48,17 +49,6 @@ export interface ReviewContext {
48
49
  onCancel: () => void;
49
50
  }
50
51
 
51
- /**
52
- * The event stream's headers: the one response that is not the page policy, because
53
- * it is a long-lived `text/event-stream` rather than a document.
54
- */
55
- const EVENT_HEADERS: Record<string, string> = {
56
- "Content-Type": "text/event-stream; charset=utf-8",
57
- "Cache-Control": "no-store",
58
- Connection: "keep-alive",
59
- "X-Accel-Buffering": "no",
60
- };
61
-
62
52
  /**
63
53
  * Parse and validate an `{ annotations }` request body. Returns the annotations,
64
54
  * or an error response to send instead.
@@ -127,7 +117,7 @@ export async function handleReviewRequest(
127
117
  if (pathname === "/" || pathname === "/index.html") {
128
118
  return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page()) };
129
119
  }
130
- if (pathname === "/events") return { status: 200, headers: { ...EVENT_HEADERS }, stream: true };
120
+ if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
131
121
  if (pathname === "/api/cancel") {
132
122
  if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to cancel.");
133
123
  ctx.onCancel();
@@ -14,7 +14,14 @@ import { randomBytes } from "node:crypto";
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
15
  import { basename } from "node:path";
16
16
  import { openInBrowser } from "../../lib/browser.ts";
17
- import { bindLoopback, createCloser, jsonHeaders, respond, type ServeResponse } from "../../lib/http/index.ts";
17
+ import {
18
+ bindLoopback,
19
+ createCloser,
20
+ jsonHeaders,
21
+ openEventStream,
22
+ respond,
23
+ type ServeResponse,
24
+ } from "../../lib/http/index.ts";
18
25
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
19
26
  import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
20
27
  import {
@@ -282,8 +289,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
282
289
  };
283
290
  }
284
291
  if (result.stream) {
285
- response.writeHead(result.status, result.headers);
286
- response.write("retry: 2000\n\n");
292
+ openEventStream(response, result);
287
293
  hadClient = true;
288
294
  clients.add(response);
289
295
  if (dropTimer) {
@@ -82,8 +82,15 @@ export abstract class ChecklistScreen<T> implements Component {
82
82
  /** A verb other than navigation, toggle, all, none, and enter. */
83
83
  protected verb?(data: string): void;
84
84
 
85
- /** The row under the cursor. */
86
- protected get focused(): T | undefined {
85
+ /**
86
+ * The row under the cursor.
87
+ *
88
+ * Not named `focused`: Pi's `Focusable` check is `"focused" in component`, and a
89
+ * component that answers it is handed the focus flag by assignment, which a
90
+ * read-only accessor turns into a process-ending `TypeError`. The checklist
91
+ * tracks its own cursor and needs no hardware cursor.
92
+ */
93
+ protected get focusedRow(): T | undefined {
87
94
  return this.rows()[this.cursor.selected];
88
95
  }
89
96
 
@@ -107,7 +114,7 @@ export abstract class ChecklistScreen<T> implements Component {
107
114
  }
108
115
  if (navigateList(data, this.cursor, this.rows().length)) return;
109
116
  if (data === " ") {
110
- const row = this.focused;
117
+ const row = this.focusedRow;
111
118
  if (row !== undefined) this.toggle(row);
112
119
  this.options.requestRender();
113
120
  return;