@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
package/lib/http/headers.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/http/server.ts
CHANGED
|
@@ -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,
|
|
9
|
-
* and
|
|
10
|
-
*
|
|
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/pdf.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The optional `unpdf` seam: lazy load, module-shape handling, and text
|
|
3
|
-
* extraction, shared by `web-access` and `
|
|
3
|
+
* extraction, shared by `web-access` and `file-read`.
|
|
4
4
|
*
|
|
5
5
|
* `unpdf` bundles a server-friendly build of PDF.js, so it is imported lazily:
|
|
6
6
|
* an extension registers on every session, and a parser should load only when a
|
|
7
7
|
* read actually needs it. `web-access` treats the package as optional and
|
|
8
|
-
* reports {@link pdfGuidance} when it is absent; `
|
|
8
|
+
* reports {@link pdfGuidance} when it is absent; `file-read` ships it as a
|
|
9
9
|
* dependency. Both get the same loader, the same merged-pages call, and the same
|
|
10
10
|
* result unwrapping, so a PDF reads the same way through either surface.
|
|
11
11
|
*
|
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
|
+
}
|
package/lib/requirements.ts
CHANGED
|
@@ -255,7 +255,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
255
255
|
optional: [gitCheck("the branch chip and the diff view")],
|
|
256
256
|
notes: ["The default-browser opener (`open` / `xdg-open` / `cmd`) is best-effort; the URL is printed either way."],
|
|
257
257
|
},
|
|
258
|
-
|
|
258
|
+
"file-read": {},
|
|
259
259
|
"file-open": {
|
|
260
260
|
notes: [
|
|
261
261
|
"The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
|
|
@@ -266,6 +266,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
266
266
|
memory: {
|
|
267
267
|
optional: [gitCheck("project-scope memory under the repository root")],
|
|
268
268
|
},
|
|
269
|
+
context: {},
|
|
269
270
|
wechat: {
|
|
270
271
|
optional: [
|
|
271
272
|
{
|
|
@@ -301,7 +302,6 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
301
302
|
],
|
|
302
303
|
},
|
|
303
304
|
};
|
|
304
|
-
|
|
305
305
|
// --- Probing ----------------------------------------------------------------
|
|
306
306
|
|
|
307
307
|
/** Run a command and report its exit code; production backs this with `pi.exec`. */
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounting a dock screen: rails suppressed, an optional repaint tick, and one
|
|
3
|
+
* teardown.
|
|
4
|
+
*
|
|
5
|
+
* Pi mounts a `ctx.ui.custom` screen in the editor slot the above-editor rails
|
|
6
|
+
* share, so every screen wraps itself in `withRailsSuppressed`. Three of them —
|
|
7
|
+
* `/jobs`, `/watch`, and `/memory` — also poll while they are open (a log pane,
|
|
8
|
+
* a line pane, a store another process can edit), and each re-derived the same
|
|
9
|
+
* `let timer` / `stopTimer` / `setInterval(...).unref?.()` / `finally` skeleton
|
|
10
|
+
* around its own component. The rails lifetime and the timer lifetime are the
|
|
11
|
+
* same window, so they are one helper rather than two wrappers each caller
|
|
12
|
+
* stacks and unwinds by hand.
|
|
13
|
+
*
|
|
14
|
+
* The timer is a seam (`RepeatTimer`), so a test drives the tick instead of
|
|
15
|
+
* waiting for one, and it is armed after the mount returns: a component whose
|
|
16
|
+
* constructor throws leaves no timer behind.
|
|
17
|
+
*
|
|
18
|
+
* Value-only: the timer handle lives in the caller's closure.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
23
|
+
import { withRailsSuppressed } from "./rails.ts";
|
|
24
|
+
import { defaultInterval, type RepeatTimer } from "./timer.ts";
|
|
25
|
+
|
|
26
|
+
/** What the host accepts back from a screen's mount. */
|
|
27
|
+
export type ScreenComponent = Component & { dispose?(): void };
|
|
28
|
+
|
|
29
|
+
/** What a mounted screen is given: the host's mount parameters, narrowed. */
|
|
30
|
+
export interface PolledScreen {
|
|
31
|
+
/** The host's component handle; a screen that needs an `Editor` builds it from this. */
|
|
32
|
+
readonly tui: TUI;
|
|
33
|
+
/** The theme the host handed the mount. */
|
|
34
|
+
readonly theme: Theme;
|
|
35
|
+
/** Ask the host to repaint. */
|
|
36
|
+
requestRender(): void;
|
|
37
|
+
/** Close the screen, as its own close key would. */
|
|
38
|
+
close(): void;
|
|
39
|
+
/** The terminal's height in rows, or `undefined` when the host cannot say. */
|
|
40
|
+
viewportRows(): number | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PolledScreenOptions<Screen> {
|
|
44
|
+
/** How often `poll` runs while the screen is open, in ms. */
|
|
45
|
+
pollMs: number;
|
|
46
|
+
/** Run once per tick; `screen` is what `mount` returned. */
|
|
47
|
+
poll(screen: Screen, handles: PolledScreen): void;
|
|
48
|
+
/** Timer seam; defaults to an unref'd interval. */
|
|
49
|
+
timer?: RepeatTimer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Show one polled dock screen and resolve when it closes.
|
|
54
|
+
*
|
|
55
|
+
* The caller still owns the `ctx.mode !== "tui"` branch, because the text
|
|
56
|
+
* fallback differs per command; everything from the mount to the teardown is
|
|
57
|
+
* here.
|
|
58
|
+
*/
|
|
59
|
+
export async function withPolledScreen<Screen extends ScreenComponent>(
|
|
60
|
+
pi: Pick<ExtensionAPI, "events">,
|
|
61
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
62
|
+
mount: (handles: PolledScreen) => Screen,
|
|
63
|
+
options: PolledScreenOptions<Screen>,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const { pollMs, poll, timer = defaultInterval } = options;
|
|
66
|
+
let stop: (() => void) | undefined;
|
|
67
|
+
try {
|
|
68
|
+
await withRailsSuppressed(pi, () =>
|
|
69
|
+
ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
70
|
+
const handles: PolledScreen = {
|
|
71
|
+
tui,
|
|
72
|
+
theme,
|
|
73
|
+
requestRender: () => tui.requestRender(),
|
|
74
|
+
close: () => done(),
|
|
75
|
+
viewportRows: () => tui.terminal?.rows,
|
|
76
|
+
};
|
|
77
|
+
const screen = mount(handles);
|
|
78
|
+
stop = timer(pollMs, () => poll(screen, handles));
|
|
79
|
+
return screen;
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
} finally {
|
|
83
|
+
stop?.();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/timer.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The timer seams: one shot, repeating, and a sleep that can be aborted.
|
|
3
|
+
*
|
|
4
|
+
* Extensions need the same three things, and each has the same two properties a
|
|
5
|
+
* test cares about: it is cancellable, and its production default never holds
|
|
6
|
+
* the process open. The WeChat bridge arms a one-shot per acknowledgement,
|
|
7
|
+
* typing keep-alive, and prompt timeout; plan's review page arms one for the
|
|
8
|
+
* closed-tab grace; `job` and `watch` repaint on a repeating one; the browse
|
|
9
|
+
* live channel needs both; `job`, `watch`, `check`, `subagent`, and `wechat`
|
|
10
|
+
* sleep between polls. Keeping the defaults here means one definition of "arm
|
|
11
|
+
* it, and let it be cancelled", and one place where the `unref` lives.
|
|
12
|
+
*
|
|
13
|
+
* Value-only: types and constant functions, no module state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Arm a one-shot timer; the returned function cancels it. */
|
|
17
|
+
export type ArmTimer = (ms: number, fn: () => void) => () => void;
|
|
18
|
+
|
|
19
|
+
/** The production timer: `setTimeout`, detached from the event loop. */
|
|
20
|
+
export const defaultTimer: ArmTimer = (ms, fn) => {
|
|
21
|
+
const timer = setTimeout(fn, ms);
|
|
22
|
+
(timer as { unref?: () => void }).unref?.();
|
|
23
|
+
return () => clearTimeout(timer);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Arm a repeating timer; the returned function stops it. */
|
|
27
|
+
export type RepeatTimer = (ms: number, tick: () => void) => () => void;
|
|
28
|
+
|
|
29
|
+
/** The production ticker: `setInterval`, detached from the event loop. */
|
|
30
|
+
export const defaultInterval: RepeatTimer = (ms, tick) => {
|
|
31
|
+
const timer = setInterval(tick, ms);
|
|
32
|
+
(timer as { unref?: () => void }).unref?.();
|
|
33
|
+
return () => clearInterval(timer);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve after `ms`, or reject when `signal` aborts first.
|
|
38
|
+
*
|
|
39
|
+
* The one rejection is `Error("aborted")` whatever the signal carries, so a
|
|
40
|
+
* caller that names its own abort error maps one case rather than inspecting a
|
|
41
|
+
* reason. The timer is cleared on both paths and never `unref`'d: a sleep exists
|
|
42
|
+
* to keep the caller waiting, so nothing else is pending to keep the process
|
|
43
|
+
* alive, and an unref'd one could be dropped mid-wait.
|
|
44
|
+
*/
|
|
45
|
+
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
if (signal?.aborted) {
|
|
48
|
+
reject(new Error("aborted"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
signal?.removeEventListener("abort", onAbort);
|
|
53
|
+
resolve();
|
|
54
|
+
}, ms);
|
|
55
|
+
const onAbort = (): void => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(new Error("aborted"));
|
|
58
|
+
};
|
|
59
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
});
|
|
61
|
+
}
|
package/lib/tui/README.md
CHANGED
|
@@ -151,6 +151,6 @@ and the first call throws at runtime.
|
|
|
151
151
|
|
|
152
152
|
## Tests
|
|
153
153
|
|
|
154
|
-
A
|
|
155
|
-
ordering, and glyphs — never literal column padding. An extension's
|
|
156
|
-
|
|
154
|
+
A capability suite groups the modules it covers and asserts fields, width
|
|
155
|
+
bounds, ordering, and glyphs — never literal column padding. An extension's
|
|
156
|
+
screen coverage lives in its declared capability suite (for example `ui.test.ts`).
|
package/lib/tui/columns.ts
CHANGED
|
@@ -15,14 +15,17 @@ import { sanitizeLine, sanitizeUntrustedLine } from "../format.ts";
|
|
|
15
15
|
/** The columns a label gets before it stops reading as a label. */
|
|
16
16
|
export const LABEL_WIDTH = 48;
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A usable drawing width in columns: terminal arithmetic starts from one column,
|
|
20
|
+
* never zero, so a narrow or unknown width never produces an empty frame.
|
|
21
|
+
*/
|
|
22
|
+
export function usableWidth(width: number): number {
|
|
20
23
|
return Math.max(1, Math.floor(width));
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** Clip a possibly-themed string to `width` columns, marking a cut with `ellipsis`. */
|
|
24
27
|
export function clip(text: string, width: number, ellipsis = "…"): string {
|
|
25
|
-
return truncateToWidth(text,
|
|
28
|
+
return truncateToWidth(text, usableWidth(width), ellipsis);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -34,7 +37,7 @@ export function clip(text: string, width: number, ellipsis = "…"): string {
|
|
|
34
37
|
* A themed string keeps its colours on both the head and the tail.
|
|
35
38
|
*/
|
|
36
39
|
export function clipMiddle(text: string, width: number, ellipsis = "…"): string {
|
|
37
|
-
const w =
|
|
40
|
+
const w = usableWidth(width);
|
|
38
41
|
if (visibleWidth(text) <= w) return text;
|
|
39
42
|
const ellipsisWidth = visibleWidth(ellipsis);
|
|
40
43
|
const budget = w - ellipsisWidth;
|
|
@@ -69,7 +72,7 @@ export function clipLabel(text: string): string {
|
|
|
69
72
|
|
|
70
73
|
/** Pad `text` on the right to exactly `width` columns. Never truncates. */
|
|
71
74
|
export function padTo(text: string, width: number): string {
|
|
72
|
-
const missing =
|
|
75
|
+
const missing = usableWidth(width) - visibleWidth(text);
|
|
73
76
|
return missing > 0 ? text + " ".repeat(missing) : text;
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -83,7 +86,7 @@ export function padTo(text: string, width: number): string {
|
|
|
83
86
|
* plain left-aligned line with no trailing spaces.
|
|
84
87
|
*/
|
|
85
88
|
export function splitRow(left: string, right: string, width: number, minGap = 1): string {
|
|
86
|
-
const w =
|
|
89
|
+
const w = usableWidth(width);
|
|
87
90
|
if (!right) return clip(left, w);
|
|
88
91
|
const gap = Math.max(0, Math.floor(minGap));
|
|
89
92
|
const rightCell = clip(right, w);
|
|
@@ -93,13 +96,35 @@ export function splitRow(left: string, right: string, width: number, minGap = 1)
|
|
|
93
96
|
return padTo(clip(left, leftBudget), w - rightWidth) + rightCell;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Left cell, then a gap, then the right cell, with the result ending after the
|
|
101
|
+
* right cell instead of filling the width.
|
|
102
|
+
*
|
|
103
|
+
* The sibling of {@link splitRow}: a label and its value read as one pair on a
|
|
104
|
+
* wide terminal instead of sitting at opposite edges, while a tight line still
|
|
105
|
+
* keeps the value — the left cell is clipped to make room, and the gap shrinks
|
|
106
|
+
* in whole columns before the right cell loses anything.
|
|
107
|
+
*/
|
|
108
|
+
export function inlineRow(left: string, right: string, width: number, gap = 2): string {
|
|
109
|
+
const w = usableWidth(width);
|
|
110
|
+
if (!right) return clip(left, w);
|
|
111
|
+
const space = Math.max(0, Math.floor(gap));
|
|
112
|
+
const rightCell = clip(right, w);
|
|
113
|
+
const rightWidth = visibleWidth(rightCell);
|
|
114
|
+
const leftBudget = w - rightWidth - space;
|
|
115
|
+
if (leftBudget <= 0) return rightCell;
|
|
116
|
+
const leftCell = clip(left, leftBudget);
|
|
117
|
+
const separator = " ".repeat(Math.max(0, Math.min(space, w - visibleWidth(leftCell) - rightWidth)));
|
|
118
|
+
return `${leftCell}${separator}${rightCell}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
/**
|
|
97
122
|
* Join as many whole cells as fit, dropping trailing cells rather than
|
|
98
123
|
* clipping one in half. The first cell is always kept, so an over-wide line
|
|
99
124
|
* degrades to a clipped first cell instead of an empty one.
|
|
100
125
|
*/
|
|
101
126
|
export function fitCells(cells: readonly string[], width: number, separator = " "): string {
|
|
102
|
-
const w =
|
|
127
|
+
const w = usableWidth(width);
|
|
103
128
|
const kept: string[] = [];
|
|
104
129
|
for (const cell of cells) {
|
|
105
130
|
const candidate = [...kept, cell].join(separator);
|
|
@@ -118,7 +143,7 @@ export function fitCells(cells: readonly string[], width: number, separator = "
|
|
|
118
143
|
* {@link fitCells} it may return several lines, and always returns one.
|
|
119
144
|
*/
|
|
120
145
|
export function flowCells(cells: readonly string[], width: number): string[] {
|
|
121
|
-
const w =
|
|
146
|
+
const w = usableWidth(width);
|
|
122
147
|
const lines: string[] = [];
|
|
123
148
|
let current = "";
|
|
124
149
|
let currentWidth = 0;
|
package/lib/tui/dock-screen.ts
CHANGED
|
@@ -136,8 +136,16 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
136
136
|
return this.options.theme;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
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.
|
|
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.
|
|
285
|
+
this.handleVerb(data, this.focusedRow);
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
private handleConfirmKey(data: string): void {
|
package/lib/tui/rows.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { clip, padTo, splitRow } from "./columns.ts";
|
|
16
|
+
import { clip, inlineRow, padTo, splitRow } from "./columns.ts";
|
|
17
17
|
import { selectionMarker } from "./cursor.ts";
|
|
18
18
|
import { GLYPHS } from "./glyphs.ts";
|
|
19
19
|
import { BODY_INDENT } from "./indent.ts";
|
|
@@ -41,21 +41,29 @@ export interface ListRow {
|
|
|
41
41
|
/** The row's text. */
|
|
42
42
|
label: string;
|
|
43
43
|
labelRole?: TuiRole;
|
|
44
|
-
/**
|
|
44
|
+
/** Meta that closes the row: right-aligned, or beside the label when `align` is `inline`. */
|
|
45
45
|
trailing?: string;
|
|
46
46
|
trailingRole?: TuiRole;
|
|
47
|
+
/**
|
|
48
|
+
* Where the trailing cell sits. `end` (the default) pushes it to the right
|
|
49
|
+
* edge, which suits a column of counts. `inline` keeps it beside the label
|
|
50
|
+
* with a two-column gap, so a label and its value read as one pair on a wide
|
|
51
|
+
* terminal instead of sitting at opposite ends of the line.
|
|
52
|
+
*/
|
|
53
|
+
align?: "end" | "inline";
|
|
47
54
|
/** Extra columns before the marker, for a nested row. */
|
|
48
55
|
indent?: number;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
59
|
* One list row: an optional indent, the selection marker, an optional glyph,
|
|
53
|
-
* the label, and
|
|
60
|
+
* the label, and the trailing meta.
|
|
54
61
|
*
|
|
55
62
|
* The marker column is reserved by default, so selected and unselected rows
|
|
56
63
|
* align; `marker: false` drops it for a non-interactive row. The trailing meta
|
|
57
|
-
* is the anchor when the terminal is narrow (see `splitRow`),
|
|
58
|
-
* end of the row survives
|
|
64
|
+
* is the anchor when the terminal is narrow (see `splitRow` and `inlineRow`),
|
|
65
|
+
* so a value at the end of the row survives either way; `align` decides whether
|
|
66
|
+
* it sits at the right edge or right after the label.
|
|
59
67
|
*/
|
|
60
68
|
export function listRow(theme: Theme, width: number, row: ListRow): string {
|
|
61
69
|
const indent = " ".repeat(Math.max(0, Math.floor(row.indent ?? 0)));
|
|
@@ -64,7 +72,8 @@ export function listRow(theme: Theme, width: number, row: ListRow): string {
|
|
|
64
72
|
const glyph = row.glyph ? `${paint(theme, row.glyphRole ?? "info", row.glyph)} ` : "";
|
|
65
73
|
const label = paint(theme, row.labelRole ?? "label", row.label);
|
|
66
74
|
const trailing = row.trailing ? paint(theme, row.trailingRole ?? "meta", row.trailing) : "";
|
|
67
|
-
|
|
75
|
+
const head = `${indent}${marker}${leading}${glyph}${label}`;
|
|
76
|
+
return row.align === "inline" ? inlineRow(head, trailing, width) : splitRow(head, trailing, width);
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
/** A status row (a job, a watcher): the marker, a status glyph, a label, and right-aligned meta. */
|
package/lib/tui/rules.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { usableWidth } from "./columns.ts";
|
|
18
19
|
import { paint, type TuiRole } from "./tokens.ts";
|
|
19
20
|
|
|
20
21
|
/** The glyph and colors a rule is drawn with. */
|
|
@@ -39,14 +40,9 @@ export interface CenteredRuleStyle extends RuleStyle {
|
|
|
39
40
|
*/
|
|
40
41
|
export const RULE_GLYPH = "─";
|
|
41
42
|
|
|
42
|
-
/** A usable origin: terminal arithmetic starts from one column, never zero. */
|
|
43
|
-
function widthOf(width: number): number {
|
|
44
|
-
return Math.max(1, Math.floor(width));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
43
|
/** A full-width divider in one color. */
|
|
48
44
|
export function plainRule(theme: Theme, width: number, style: RuleStyle = {}): string {
|
|
49
|
-
const w =
|
|
45
|
+
const w = usableWidth(width);
|
|
50
46
|
return paint(theme, style.fillRole ?? "rule", (style.glyph ?? RULE_GLYPH).repeat(w));
|
|
51
47
|
}
|
|
52
48
|
|
|
@@ -64,7 +60,7 @@ function labelRule(
|
|
|
64
60
|
fillRole: TuiRole,
|
|
65
61
|
style: RuleStyle,
|
|
66
62
|
): string {
|
|
67
|
-
const w =
|
|
63
|
+
const w = usableWidth(width);
|
|
68
64
|
const text = label.trim();
|
|
69
65
|
if (!text) return plainRule(theme, w, style);
|
|
70
66
|
const labelWidth = visibleWidth(text);
|
|
@@ -75,7 +71,7 @@ function labelRule(
|
|
|
75
71
|
|
|
76
72
|
/** A centered rule: fill, label, and the rest of the fill. */
|
|
77
73
|
export function centeredRule(theme: Theme, width: number, label: string, style: CenteredRuleStyle = {}): string {
|
|
78
|
-
const w =
|
|
74
|
+
const w = usableWidth(width);
|
|
79
75
|
const glyph = style.glyph ?? RULE_GLYPH;
|
|
80
76
|
const fillRole = style.fillRole ?? "ruleStrong";
|
|
81
77
|
if (!label.trim()) return paint(theme, fillRole, glyph.repeat(w));
|
package/lib/tui/tool.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The shared tool-transcript kit: a call title, a
|
|
3
|
-
*
|
|
2
|
+
* The shared tool-transcript kit: a call title, the call line a renderer draws
|
|
3
|
+
* with it, the failure line a result shows before its body, the expand hint, and
|
|
4
|
+
* the running timer.
|
|
4
5
|
*
|
|
5
6
|
* Every extension's `renderCall` / `renderResult` used to repeat the same four
|
|
6
7
|
* idioms — a bold `toolTitle`, a raw `result.content[0]`, a hand-built
|
|
@@ -34,6 +35,58 @@ export function toolCallTitle(theme: Theme, name: string): string {
|
|
|
34
35
|
return theme.fg("toolTitle", theme.bold(name));
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* One call line: the bold tool name, then the subject in the meta role.
|
|
40
|
+
*
|
|
41
|
+
* The subject is flattened to one line here, because a call line is a label: a
|
|
42
|
+
* stored note, a task, or a path carrying a newline cannot wrap it into the body
|
|
43
|
+
* column, where it would read as a result. Pass the clip the subject deserves —
|
|
44
|
+
* `middle` for a path or a URL whose tail is the identity, `end` for prose.
|
|
45
|
+
*
|
|
46
|
+
* A subject drawn in another role (an argument value in `label`, a suffix in
|
|
47
|
+
* `faint`) stays hand-built at its call site; this is the plain headline.
|
|
48
|
+
*/
|
|
49
|
+
export function callLine(
|
|
50
|
+
theme: Theme,
|
|
51
|
+
last: unknown,
|
|
52
|
+
name: string,
|
|
53
|
+
subject: string,
|
|
54
|
+
clip: CallClip = "end",
|
|
55
|
+
): ToolCallText {
|
|
56
|
+
const text = reuseCallText(last, "", clip);
|
|
57
|
+
text.setText(toolCallTitle(theme, `${name} `) + paint(theme, "meta", sanitizeLine(subject)));
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A result resolved to its details, or to the one line a failure shows.
|
|
63
|
+
*/
|
|
64
|
+
export type DetailsOrLine<Details> = { line: string } | { details: Details };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a result to its details, or to the line to show instead.
|
|
68
|
+
*
|
|
69
|
+
* The failure is one branch whether the call carried no details at all (Pi's
|
|
70
|
+
* `{ details: {}, isError: true }` envelope) or the extension reported its own
|
|
71
|
+
* `details.error`, and it goes through `errorLine`: a multi-line message keeps
|
|
72
|
+
* its lines and `Error:` is prefixed exactly once, which a hand-built
|
|
73
|
+
* `paint(theme, "bad", \`Error: ${sanitizeLine(...)}\`)` does not.
|
|
74
|
+
*/
|
|
75
|
+
export function detailsOrLine<Details extends object>(
|
|
76
|
+
theme: Theme,
|
|
77
|
+
result: ToolMessageResult,
|
|
78
|
+
expanded: boolean,
|
|
79
|
+
details: Details | undefined,
|
|
80
|
+
): DetailsOrLine<Details> {
|
|
81
|
+
if (!details) return { line: rejectionLine(theme, result, expanded) };
|
|
82
|
+
// The one property this reads is the extension's own report field; a details
|
|
83
|
+
// block without it (a plan summary, a check report) simply has no failure.
|
|
84
|
+
if ("error" in details && typeof details.error === "string" && details.error !== "") {
|
|
85
|
+
return { line: errorLine(theme, details.error) };
|
|
86
|
+
}
|
|
87
|
+
return { details };
|
|
88
|
+
}
|
|
89
|
+
|
|
37
90
|
/** How a call line gives up columns when the terminal is narrow. */
|
|
38
91
|
export type CallClip = "end" | "middle";
|
|
39
92
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
3
|
+
"version": "0.16.0",
|
|
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, file-read, check, memory, context, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"open_path",
|
|
40
40
|
"reveal",
|
|
41
41
|
"doc",
|
|
42
|
-
"
|
|
42
|
+
"file-read",
|
|
43
43
|
"doctor",
|
|
44
44
|
"diagnostics",
|
|
45
45
|
"prerequisites",
|
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
"read_doc",
|
|
51
51
|
"memory",
|
|
52
52
|
"cross-session",
|
|
53
|
+
"context",
|
|
54
|
+
"compaction",
|
|
55
|
+
"tokens",
|
|
53
56
|
"wechat",
|
|
54
57
|
"web-access",
|
|
55
58
|
"bytefly",
|
|
@@ -94,9 +97,10 @@
|
|
|
94
97
|
"./extensions/watch/index.ts",
|
|
95
98
|
"./extensions/web-access/index.ts",
|
|
96
99
|
"./extensions/file-browse/index.ts",
|
|
97
|
-
"./extensions/
|
|
100
|
+
"./extensions/file-read/index.ts",
|
|
98
101
|
"./extensions/file-open/index.ts",
|
|
99
102
|
"./extensions/memory/index.ts",
|
|
103
|
+
"./extensions/context/index.ts",
|
|
100
104
|
"./extensions/wechat/index.ts",
|
|
101
105
|
"./extensions/bytefly/index.ts"
|
|
102
106
|
],
|