@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.
@@ -20,6 +20,7 @@ import {
20
20
  gitDir,
21
21
  repoRoot,
22
22
  revParse,
23
+ revParseMany,
23
24
  runGitOrThrow as run,
24
25
  type RunGit,
25
26
  } from "../../lib/git/index.ts";
@@ -27,7 +28,7 @@ import { parseNameStatus, parseNumStat, type ChangedPath, type PathStat } from "
27
28
 
28
29
  // Re-export exactly the shared helpers this extension consumes, so rewind code
29
30
  // has one git import surface instead of reaching into `lib/git` directly.
30
- export { GitError, currentBranch, gitDir, repoRoot, revParse, type RunGit };
31
+ export { GitError, currentBranch, gitDir, repoRoot, revParse, revParseMany, type RunGit };
31
32
 
32
33
  /** Timeout budget for one git command issued by this extension. */
33
34
  const SNAPSHOT_TIMEOUT_MS = 30_000;
@@ -262,20 +263,32 @@ export async function applyPatchReverse(runGit: RunGit, cwd: string, patch: stri
262
263
  }
263
264
  }
264
265
 
265
- /** Label of an in-progress git operation that a rewind must not clobber, if any. */
266
+ /**
267
+ * The markers an interrupted git operation leaves, in precedence order: the
268
+ * first one present names the operation a rewind would clobber.
269
+ */
270
+ const BUSY_MARKERS: ReadonlyArray<readonly [string, string]> = [
271
+ ["MERGE_HEAD", "merge"],
272
+ ["rebase-merge", "rebase"],
273
+ ["rebase-apply", "rebase"],
274
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
275
+ ["REVERT_HEAD", "revert"],
276
+ ["BISECT_LOG", "bisect"],
277
+ ];
278
+
279
+ /**
280
+ * Label of an in-progress git operation that a rewind must not clobber, if any.
281
+ *
282
+ * `rev-parse --git-path` prints one path per argument, so all six markers are one
283
+ * git call: a clean repository — the common case, where every one of them is
284
+ * missing — would otherwise pay six spawns to learn nothing is in progress.
285
+ */
266
286
  export async function busyGitState(runGit: RunGit, cwd: string): Promise<string | undefined> {
267
- const markers: Array<[string, string]> = [
268
- ["MERGE_HEAD", "merge"],
269
- ["rebase-merge", "rebase"],
270
- ["rebase-apply", "rebase"],
271
- ["CHERRY_PICK_HEAD", "cherry-pick"],
272
- ["REVERT_HEAD", "revert"],
273
- ["BISECT_LOG", "bisect"],
274
- ];
275
- for (const [name, label] of markers) {
276
- const result = await runGit(["rev-parse", "--git-path", name], { cwd });
277
- if (result.code !== 0) continue;
278
- const path = result.stdout.trim();
287
+ const result = await runGit(["rev-parse", ...BUSY_MARKERS.flatMap(([name]) => ["--git-path", name])], { cwd });
288
+ if (result.code !== 0) return undefined;
289
+ const paths = result.stdout.split("\n");
290
+ for (const [index, [, label]] of BUSY_MARKERS.entries()) {
291
+ const path = paths[index]?.trim();
279
292
  if (path && existsSync(resolve(cwd, path))) return label;
280
293
  }
281
294
  return undefined;
@@ -94,7 +94,7 @@ export class HunkPickerComponent extends ChecklistScreen<HunkRow> {
94
94
 
95
95
  /** The focused hunk's first removed and added line. */
96
96
  protected detailLines(width: number): string[] {
97
- const focused = this.focused;
97
+ const focused = this.focusedRow;
98
98
  if (!focused) return [];
99
99
  return previewOf(focused.hunk).map((line) => {
100
100
  const role = line.startsWith("+") ? "ok" : line.startsWith("-") ? "bad" : "meta";
@@ -137,7 +137,7 @@ export class PathPickerComponent extends ChecklistScreen<Row> {
137
137
 
138
138
  /** What the focused row will do, in words. */
139
139
  protected detailLines(width: number): string[] {
140
- const row = this.focused;
140
+ const row = this.focusedRow;
141
141
  if (!row) return [];
142
142
  const text =
143
143
  row.kind === "group"
@@ -133,7 +133,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
133
133
  }
134
134
 
135
135
  protected onEnter(): void {
136
- const entry = this.focused;
136
+ const entry = this.focusedRow;
137
137
  if (entry) this.close({ kind: "diff", path: entry.path });
138
138
  }
139
139
 
@@ -142,7 +142,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
142
142
  }
143
143
 
144
144
  protected verb(data: string): void {
145
- const entry = this.focused;
145
+ const entry = this.focusedRow;
146
146
  if (!entry) return;
147
147
  if (data === "r") {
148
148
  const paths = this.chosen.size > 0 ? [...this.chosen] : [entry.path];
@@ -174,7 +174,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
174
174
 
175
175
  protected detailLines(width: number): string[] {
176
176
  const detail: string[] = [];
177
- const focused = this.focused;
177
+ const focused = this.focusedRow;
178
178
  if (focused) detail.push(clip(` ${paint(this.theme, "meta", formatReviewDetail(focused))}`, width));
179
179
  const selectionLine =
180
180
  this.chosen.size > 0
@@ -8,7 +8,15 @@
8
8
  * touched: all staging happens in a temporary index.
9
9
  */
10
10
 
11
- import { commitTree, currentBranch, revParse, treeFromWorkingTree, updateRef, GitError, type RunGit } from "./git.ts";
11
+ import {
12
+ commitTree,
13
+ currentBranch,
14
+ treeFromWorkingTree,
15
+ updateRef,
16
+ GitError,
17
+ revParseMany,
18
+ type RunGit,
19
+ } from "./git.ts";
12
20
  import { encodeMessage, refFor, META_VERSION, type SnapshotMeta } from "./store.ts";
13
21
  import type { Snapshot, SnapshotReason } from "./types.ts";
14
22
 
@@ -44,11 +52,12 @@ function defaultId(now: number): string {
44
52
  /** Point a ref at the current working tree and return the recorded snapshot. */
45
53
  export async function createSnapshot(deps: SnapshotDeps, input: SnapshotInput): Promise<Snapshot> {
46
54
  const { runGit } = deps;
47
- const head = await revParse(runGit, input.root, "HEAD");
48
- if (!head) throw new GitError("the repository has no commits to snapshot.", 1);
55
+ // One `rev-parse` for both ids: the snapshot needs HEAD to parent the commit
56
+ // and HEAD's tree to know whether the working tree is clean.
57
+ const [head, headTree] = await revParseMany(runGit, input.root, ["HEAD", "HEAD^{tree}"]);
58
+ if (!head || !headTree) throw new GitError("the repository has no commits to snapshot.", 1);
49
59
 
50
60
  const tree = await treeFromWorkingTree(runGit, input.root, input.indexFile, input.includeUntracked);
51
- const headTree = await revParse(runGit, input.root, "HEAD^{tree}");
52
61
  const clean = tree === headTree;
53
62
 
54
63
  const now = deps.now?.() ?? Date.now();
@@ -161,7 +161,7 @@ export class AgentsBoardComponent extends DockListScreen<BackgroundRun> {
161
161
  }
162
162
 
163
163
  protected listHints(): ScreenHint[] {
164
- const focused = this.focused;
164
+ const focused = this.focusedRow;
165
165
  const hints: ScreenHint[] = ["Esc close", { text: "Enter detail", color: "accent" }];
166
166
  if (focused && canCollect(focused)) hints.push({ text: "c collect", color: "accent" });
167
167
  if (focused?.status === "running") hints.push({ text: "x cancel", color: "warning" });
@@ -6,9 +6,10 @@
6
6
  import { basename } from "node:path";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { realPathOfNearest } from "../../lib/path.ts";
9
+ import { mapConcurrent } from "../../lib/pool.ts";
9
10
  import { loadConfig } from "./config.ts";
10
11
  import { gapLines, isolationGaps } from "./gaps.ts";
11
- import { type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
12
+ import { type CheckoutCheck, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
12
13
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
13
14
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
14
15
  import type { WorktreeState } from "./state.ts";
@@ -115,15 +116,19 @@ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): P
115
116
  const { registry } = reconcileRegistry(root, config, managed, { persist: false });
116
117
  const baseBranch = await defaultBranch(pi, root);
117
118
  const baseRef = baseBranch ? `origin/${baseBranch}` : undefined;
118
- lines.push("", `Managed worktrees (${managed.length}):`);
119
- for (const entry of managed) {
120
- const record = registry.worktrees.find((candidate) => candidate.path === realPathOfNearest(entry.path));
121
- const status = await statusOf(pi, entry, { baseRef, record });
119
+ // Keyed by the canonical path the registry stores, so a row's record is one
120
+ // lookup instead of a scan of every record.
121
+ const records = new Map(registry.worktrees.map((record) => [record.path, record]));
122
+ const row = async (entry: ManagedWorktree): Promise<string> => {
123
+ const path = realPathOfNearest(entry.path);
124
+ const status = await statusOf(pi, entry, { baseRef, record: records.get(path) });
122
125
  const marks = statusMarks(status, current?.path === entry.path);
123
- lines.push(
124
- ` ${entry.path} ${entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd(),
125
- );
126
- }
126
+ return ` ${entry.path} ${entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd();
127
+ };
128
+ // Each row is up to four git reads; four at a time keeps the list in the order
129
+ // git reports while overlapping the waits. `statusOf` passes no `env`, which is
130
+ // what makes concurrent git calls safe here (see `RunGitOptions.env`).
131
+ lines.push("", `Managed worktrees (${managed.length}):`, ...(await mapConcurrent(managed, 4, row)));
127
132
  }
128
133
  return lines.join("\n");
129
134
  }
package/lib/README.md CHANGED
@@ -27,10 +27,11 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
27
27
  | [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
28
28
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
29
29
  | [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
30
+ | [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
30
31
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
31
- | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
32
+ | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
32
33
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
33
- | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `plan`: [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/server.ts`](./http/server.ts) (`bindLoopback` on `127.0.0.1` with the accept-error listener, `respond` for HEAD / streamed file / body, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
34
+ | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `plan`: [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets, plus `eventHeaders` for the one long-lived stream), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/server.ts`](./http/server.ts) (`bindLoopback` on `127.0.0.1` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream` for the head and reconnect hint of an SSE response, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
34
35
  | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame) with `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
35
36
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
36
37
  | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `document`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
package/lib/git/read.ts CHANGED
@@ -70,6 +70,26 @@ export async function revParse(runGit: RunGit, cwd: string, rev: string): Promis
70
70
  return id || undefined;
71
71
  }
72
72
 
73
+ /**
74
+ * Resolve several revisions in one `git rev-parse`, in the order given.
75
+ *
76
+ * A snapshot needs `HEAD` and its tree, and two calls pay two spawns for one
77
+ * answer; `rev-parse` prints one line per argument, so one call is enough. A
78
+ * revision that does not resolve is not a shorter list — it is no answer, since
79
+ * the caller's indices would shift — so the result is `[]` unless every one
80
+ * resolved.
81
+ */
82
+ export async function revParseMany(runGit: RunGit, cwd: string, revs: readonly string[]): Promise<string[]> {
83
+ if (revs.length === 0) return [];
84
+ const result = await runGit(["rev-parse", ...revs], { cwd });
85
+ if (result.code !== 0) return [];
86
+ const ids = result.stdout
87
+ .split("\n")
88
+ .map((line) => line.trim())
89
+ .filter((line) => line !== "");
90
+ return ids.length === revs.length ? ids : [];
91
+ }
92
+
73
93
  /** Whether the repository has at least one commit. */
74
94
  export async function hasCommits(runGit: RunGit, cwd: string): Promise<boolean> {
75
95
  return (await revParse(runGit, cwd, "HEAD")) !== undefined;
@@ -52,3 +52,23 @@ export function scriptHeaders(): Record<string, string> {
52
52
  export function jsonHeaders(): Record<string, string> {
53
53
  return secured("application/json; charset=utf-8");
54
54
  }
55
+
56
+ /**
57
+ * Headers for a Server-Sent Events stream: the one response that is not the page
58
+ * policy, because it is a long-lived event stream rather than a document. It
59
+ * carries no policy of its own to relax — the page that opens it is the one
60
+ * governed by `PAGE_CSP` — but it is declared here so both loopback servers
61
+ * answer a stream the same way.
62
+ *
63
+ * `no-store` stops a cached stream from replaying stale status, `keep-alive`
64
+ * names the connection's intent, and `X-Accel-Buffering: no` is for any
65
+ * buffering reverse proxy in front of it, which would otherwise hold the frames.
66
+ */
67
+ export function eventHeaders(): Record<string, string> {
68
+ return {
69
+ "Content-Type": "text/event-stream; charset=utf-8",
70
+ "Cache-Control": "no-store",
71
+ Connection: "keep-alive",
72
+ "X-Accel-Buffering": "no",
73
+ };
74
+ }
@@ -5,9 +5,10 @@
5
5
  * Everything here is behavior a caller cannot get right by accident: a bind on
6
6
  * `127.0.0.1` only, an accept-level `error` listener so a transient failure
7
7
  * cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
8
- * response streamed instead of buffered, and a close that is safe to call twice
9
- * and drops keep-alive connections. What each server does *around* this — SSE
10
- * streams, request bodies, session settling — stays with that server.
8
+ * response streamed instead of buffered, a `stream` response opened as an event
9
+ * stream, and a close that is safe to call twice and drops keep-alive
10
+ * connections. What each server does *around* this — which frames an event
11
+ * stream carries, request bodies, session settling — stays with that server.
11
12
  */
12
13
 
13
14
  import { createReadStream } from "node:fs";
@@ -42,7 +43,7 @@ export async function bindLoopback(server: Server, port: number): Promise<number
42
43
  *
43
44
  * A `stream` response is deliberately not handled here: keeping a connection
44
45
  * open is the owning server's lifecycle, so it must check `stream` before
45
- * calling this.
46
+ * calling this and open the stream itself through {@link openEventStream}.
46
47
  *
47
48
  * `afterFlush` runs once a buffered body is on the wire, which is the point a
48
49
  * server may resolve a session without the client seeing a dropped connection.
@@ -68,6 +69,23 @@ export function respond(
68
69
  response.end(result.body ?? "", afterFlush);
69
70
  }
70
71
 
72
+ /**
73
+ * Write the head of a Server-Sent Events response and start its stream: the
74
+ * status and headers, then a `retry:` hint for the browser's own reconnect.
75
+ *
76
+ * The connection itself stays the calling server's lifecycle — `respond` must
77
+ * not be called after this, and the caller owns ending the response and any
78
+ * periodic frames.
79
+ */
80
+ export function openEventStream(
81
+ response: ServerResponse,
82
+ result: Pick<ServeResponse, "status" | "headers">,
83
+ retryMs = 2000,
84
+ ): void {
85
+ response.writeHead(result.status, result.headers);
86
+ response.write(`retry: ${retryMs}\n\n`);
87
+ }
88
+
71
89
  /**
72
90
  * An idempotent `close()` for `server`.
73
91
  *
package/lib/pool.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Bounded concurrency for a fan-out over I/O.
3
+ *
4
+ * A directory listing and a managed-worktree status sweep both run one small
5
+ * read per entry — a `stat`, a git query — and each read is dominated by
6
+ * waiting, not by work. Run strictly in sequence, a directory of three thousand
7
+ * files pays three thousand round trips; run all at once, it hands the
8
+ * filesystem (or a git index lock) an unbounded burst. This is the middle: at
9
+ * most `limit` calls in flight, results in input order.
10
+ *
11
+ * The first rejection stops new work and is rethrown once every worker has
12
+ * settled, so a failure cannot leave a rejected promise unobserved.
13
+ *
14
+ * Value-only: one pure function over its arguments, no module state.
15
+ */
16
+
17
+ /**
18
+ * Run `fn` over `items` with at most `limit` calls in flight, in input order.
19
+ *
20
+ * `limit` is clamped to `[1, items.length]`, so a caller may pass a configured
21
+ * width without guarding it. `fn` receives the item and its index; the returned
22
+ * array is in `items` order regardless of completion order. An empty input never
23
+ * calls `fn`.
24
+ */
25
+ export async function mapConcurrent<T, R>(
26
+ items: readonly T[],
27
+ limit: number,
28
+ fn: (item: T, index: number) => Promise<R>,
29
+ ): Promise<R[]> {
30
+ const total = items.length;
31
+ if (total === 0) return [];
32
+ const width = Math.min(Math.max(1, Math.floor(limit)), total);
33
+ const results = new Array<R>(total);
34
+
35
+ let next = 0;
36
+ let failed = false;
37
+ let failure: unknown;
38
+
39
+ /** Take the next unclaimed index and run it; stop taking after a failure. */
40
+ const worker = async (): Promise<void> => {
41
+ for (;;) {
42
+ if (failed) return;
43
+ const index = next++;
44
+ if (index >= total) return;
45
+ try {
46
+ results[index] = await fn(items[index], index);
47
+ } catch (error) {
48
+ // Keep the first failure: a later one is a consequence of the work
49
+ // already in flight, not the cause the caller needs to read.
50
+ if (!failed) {
51
+ failed = true;
52
+ failure = error;
53
+ }
54
+ return;
55
+ }
56
+ }
57
+ };
58
+
59
+ await Promise.all(Array.from({ length: width }, worker));
60
+ if (failed) throw failure;
61
+ return results;
62
+ }
@@ -136,8 +136,16 @@ export abstract class DockListScreen<T> implements Component {
136
136
  return this.options.theme;
137
137
  }
138
138
 
139
- /** The row under the cursor, if any. */
140
- protected get focused(): T | undefined {
139
+ /**
140
+ * The row under the cursor, if any.
141
+ *
142
+ * Not named `focused`: Pi's `Focusable` check is `"focused" in component`, and a
143
+ * component that answers it is handed the focus flag by assignment
144
+ * (`component.focused = true`). A read-only accessor under that name throws a
145
+ * `TypeError` on the way back to the editor and takes the process with it. This
146
+ * screen paints its own selection marker and needs no hardware cursor.
147
+ */
148
+ protected get focusedRow(): T | undefined {
141
149
  const rows = this.rows();
142
150
  return rows[this.cursor.indexOf(rows, (row) => this.idOf(row))];
143
151
  }
@@ -229,7 +237,7 @@ export abstract class DockListScreen<T> implements Component {
229
237
  }
230
238
 
231
239
  private openDetail(): void {
232
- const row = this.focused;
240
+ const row = this.focusedRow;
233
241
  if (!row) return;
234
242
  const contents = this.detailFor(row);
235
243
  this.mode = "detail";
@@ -274,7 +282,7 @@ export abstract class DockListScreen<T> implements Component {
274
282
  this.openDetail();
275
283
  return;
276
284
  }
277
- this.handleVerb(data, this.focused);
285
+ this.handleVerb(data, this.focusedRow);
278
286
  }
279
287
 
280
288
  private handleConfirmKey(data: string): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, check, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",