@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
package/lib/repo-key.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-repository storage key that outlives one checkout.
|
|
3
|
+
*
|
|
4
|
+
* Two extensions file state by repository — `session-bus` its sessions, mail, and
|
|
5
|
+
* claims, `plan` its plans — and both must resolve the same key from any
|
|
6
|
+
* worktree, so a plan approved in `.pi/worktrees/x` is the same plan from the
|
|
7
|
+
* main checkout. The key is the repository's *common* git directory, which every
|
|
8
|
+
* worktree of that repository shares; outside a repository the working directory
|
|
9
|
+
* is the identity.
|
|
10
|
+
*
|
|
11
|
+
* Hashing keeps a mail directory, a claims directory, and a plans directory short
|
|
12
|
+
* enough to read and safe on every filesystem, while staying unique per
|
|
13
|
+
* repository.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { realpathSync } from "node:fs";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { createExecRunner, gitCommonDir, repoRoot } from "./git/index.ts";
|
|
20
|
+
|
|
21
|
+
/** Hex characters kept from the digest: collision-free in practice and short enough for a path. */
|
|
22
|
+
export const REPO_KEY_LENGTH = 12;
|
|
23
|
+
|
|
24
|
+
/** The key for one identity string, hashed and shortened. */
|
|
25
|
+
export function repoKeyOf(value: string): string {
|
|
26
|
+
return createHash("sha256").update(canonical(value)).digest("hex").slice(0, REPO_KEY_LENGTH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One path, one spelling.
|
|
31
|
+
*
|
|
32
|
+
* git prints the path it was told to work from, so a repository reached through a
|
|
33
|
+
* symlinked prefix reports a different common dir for the same repository. Two
|
|
34
|
+
* spellings would file one repository's plans and claims under two keys, so the
|
|
35
|
+
* real path is what gets hashed; a path that cannot be resolved keeps its own
|
|
36
|
+
* spelling rather than failing.
|
|
37
|
+
*/
|
|
38
|
+
function canonical(value: string): string {
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync.native(value);
|
|
41
|
+
} catch {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A resolved repository identity. */
|
|
47
|
+
export interface RepoKey {
|
|
48
|
+
/** Absolute repository root, or `cwd` when git did not answer. */
|
|
49
|
+
root: string;
|
|
50
|
+
/** The shared key, derived from the common git directory. */
|
|
51
|
+
key: string;
|
|
52
|
+
/**
|
|
53
|
+
* Whether git resolved the repository. A caller that needs a *project-local*
|
|
54
|
+
* location branches on this; one that only needs a stable directory does not.
|
|
55
|
+
*/
|
|
56
|
+
fromGit: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the repository identity for `cwd`.
|
|
61
|
+
*
|
|
62
|
+
* Never throws: without git, or when a git call fails, the working directory is
|
|
63
|
+
* the identity — which is what a caller outside a repository wants anyway.
|
|
64
|
+
*/
|
|
65
|
+
export async function repoKeyFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<RepoKey> {
|
|
66
|
+
try {
|
|
67
|
+
const run = createExecRunner(pi);
|
|
68
|
+
const [common, root] = await Promise.all([gitCommonDir(run, cwd), repoRoot(run, cwd)]);
|
|
69
|
+
if (root) return { root, key: repoKeyOf(common ?? root), fromGit: true };
|
|
70
|
+
} catch {
|
|
71
|
+
// Fall through: no git, or a git call that failed, is the same answer here.
|
|
72
|
+
}
|
|
73
|
+
return { root: cwd, key: repoKeyOf(cwd), fromGit: false };
|
|
74
|
+
}
|
package/lib/report.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* The pending-report coordinator: how a live feature tells the model what
|
|
3
3
|
* happened without interrupting it.
|
|
4
4
|
*
|
|
5
|
-
* A watcher match
|
|
6
|
-
* and raise at most one triggered turn when the work
|
|
7
|
-
*
|
|
5
|
+
* A watcher match, a finished job, and arriving mail follow one rule — report at
|
|
6
|
+
* the next turn, and raise at most one triggered turn per burst when the work
|
|
7
|
+
* opted into `wake`, as soon as the agent is idle. The three traps are subtle
|
|
8
|
+
* enough to have been re-derived once per extension:
|
|
8
9
|
*
|
|
9
10
|
* - a wake is only safe while the agent is idle, so an outcome that arrives
|
|
10
|
-
* mid-turn
|
|
11
|
+
* mid-turn is queued rather than sent, and `agent_settled` retries it;
|
|
12
|
+
* - an outcome outlives the run it arrived in, so the retry asks
|
|
13
|
+
* `wakeRequested()` rather than trusting the one call that queued it;
|
|
11
14
|
* - between the triggered send and the `agent_start` it causes, the agent still
|
|
12
15
|
* looks idle, so a second outcome in that window must not trigger a second
|
|
13
16
|
* turn. `waking` closes that window.
|
|
@@ -33,6 +36,11 @@ export interface PendingReporterOptions<T> {
|
|
|
33
36
|
markReported(items: T[]): void;
|
|
34
37
|
/** Build the notice for one burst: model text plus renderer details. */
|
|
35
38
|
buildNotice(items: T[]): NoticeMessage;
|
|
39
|
+
/**
|
|
40
|
+
* Whether any pending outcome asks for a triggered turn. Called by the settle
|
|
41
|
+
* retry, so a mid-turn outcome still wakes once the agent is idle.
|
|
42
|
+
*/
|
|
43
|
+
wakeRequested(): boolean;
|
|
36
44
|
/** The notice's source, so only its own stale entries are pruned. */
|
|
37
45
|
source: string;
|
|
38
46
|
/** Runs after a wake turn was successfully requested, for an extra announcement. */
|
|
@@ -47,8 +55,8 @@ export interface PendingReporter {
|
|
|
47
55
|
sessionStart(): void;
|
|
48
56
|
/** A turn began: outcomes queue up, and none of them triggers a turn. */
|
|
49
57
|
agentStart(): void;
|
|
50
|
-
/** The turn settled: a
|
|
51
|
-
agentSettled(): void;
|
|
58
|
+
/** The turn settled: retry a wake it suppressed, unless it was aborted. */
|
|
59
|
+
agentSettled(event?: { aborted?: boolean }): void;
|
|
52
60
|
/** Report what is queued at the start of a turn, without triggering one. */
|
|
53
61
|
beforeAgentStart(): { message: NoticeMessage } | undefined;
|
|
54
62
|
/** Drop this source's older notices from the outgoing context. */
|
|
@@ -56,26 +64,29 @@ export interface PendingReporter {
|
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
|
|
59
|
-
const { wake, pending, markReported, buildNotice, source, onWoke } = options;
|
|
67
|
+
const { wake, pending, markReported, buildNotice, wakeRequested, source, onWoke } = options;
|
|
60
68
|
let busy = false;
|
|
61
69
|
let waking = false;
|
|
62
70
|
|
|
71
|
+
const report = (): void => {
|
|
72
|
+
if (busy || waking) return;
|
|
73
|
+
if (!wakeRequested()) return;
|
|
74
|
+
const items = pending();
|
|
75
|
+
if (items.length === 0) return;
|
|
76
|
+
try {
|
|
77
|
+
wake(buildNotice(items));
|
|
78
|
+
} catch {
|
|
79
|
+
// Reporting is best-effort; the next turn reports the same items.
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Mark only after the send succeeded, so a throw changes nothing.
|
|
83
|
+
markReported(items);
|
|
84
|
+
waking = true;
|
|
85
|
+
onWoke?.(items);
|
|
86
|
+
};
|
|
87
|
+
|
|
63
88
|
return {
|
|
64
|
-
report
|
|
65
|
-
if (busy || waking) return;
|
|
66
|
-
const items = pending();
|
|
67
|
-
if (items.length === 0) return;
|
|
68
|
-
try {
|
|
69
|
-
wake(buildNotice(items));
|
|
70
|
-
} catch {
|
|
71
|
-
// Reporting is best-effort; the next turn reports the same items.
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
// Mark only after the send succeeded, so a throw changes nothing.
|
|
75
|
-
markReported(items);
|
|
76
|
-
waking = true;
|
|
77
|
-
onWoke?.(items);
|
|
78
|
-
},
|
|
89
|
+
report,
|
|
79
90
|
sessionStart: () => {
|
|
80
91
|
busy = false;
|
|
81
92
|
waking = false;
|
|
@@ -83,9 +94,13 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
|
|
|
83
94
|
agentStart: () => {
|
|
84
95
|
busy = true;
|
|
85
96
|
},
|
|
86
|
-
agentSettled: () => {
|
|
97
|
+
agentSettled: (event) => {
|
|
87
98
|
busy = false;
|
|
88
99
|
waking = false;
|
|
100
|
+
// An interrupted run is not a request to interrupt again; its outcomes
|
|
101
|
+
// stay queued for the next turn.
|
|
102
|
+
if (event?.aborted === true) return;
|
|
103
|
+
report();
|
|
89
104
|
},
|
|
90
105
|
beforeAgentStart: () => {
|
|
91
106
|
const items = pending();
|
|
@@ -103,10 +118,14 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
|
|
|
103
118
|
|
|
104
119
|
/** The session-scoped state a channel drives, narrowed to what its hooks need. */
|
|
105
120
|
export interface PendingChannelRuntime {
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Load this session's records on every session start. A promise is awaited, so a
|
|
123
|
+
* runtime that resolves a repository or opens a store is fully loaded before the
|
|
124
|
+
* report trigger is armed.
|
|
125
|
+
*/
|
|
126
|
+
load(ctx: ExtensionContext): void | Promise<void>;
|
|
127
|
+
/** Repaint the status chip; a runtime that publishes none omits it. */
|
|
128
|
+
setStatus?(ctx: ExtensionContext): void;
|
|
110
129
|
/** Release the session's resources; idempotent. */
|
|
111
130
|
shutdown(): Promise<void>;
|
|
112
131
|
}
|
|
@@ -135,29 +154,29 @@ export function registerPendingChannel(
|
|
|
135
154
|
pi: Pick<ExtensionAPI, "on">,
|
|
136
155
|
{ reporter, runtime, onSessionStart, onSessionShutdown }: PendingChannelOptions,
|
|
137
156
|
): void {
|
|
138
|
-
pi.on("session_start", (_event, ctx) => {
|
|
157
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
139
158
|
reporter.sessionStart();
|
|
140
|
-
runtime.load(ctx);
|
|
159
|
+
await runtime.load(ctx);
|
|
141
160
|
onSessionStart?.(ctx);
|
|
142
161
|
});
|
|
143
162
|
|
|
144
163
|
// Tree navigation does not change the pending records; only repaint.
|
|
145
164
|
pi.on("session_tree", (_event, ctx) => {
|
|
146
|
-
runtime.setStatus(ctx);
|
|
165
|
+
runtime.setStatus?.(ctx);
|
|
147
166
|
});
|
|
148
167
|
|
|
149
168
|
pi.on("agent_start", () => {
|
|
150
169
|
reporter.agentStart();
|
|
151
170
|
});
|
|
152
|
-
pi.on("agent_settled", () => {
|
|
153
|
-
reporter.agentSettled();
|
|
171
|
+
pi.on("agent_settled", (event) => {
|
|
172
|
+
reporter.agentSettled(event);
|
|
154
173
|
});
|
|
155
174
|
|
|
156
175
|
// Report what is queued at the start of the next turn, without triggering one.
|
|
157
176
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
158
177
|
const reported = reporter.beforeAgentStart();
|
|
159
178
|
if (!reported) return undefined;
|
|
160
|
-
runtime.setStatus(ctx);
|
|
179
|
+
runtime.setStatus?.(ctx);
|
|
161
180
|
return reported;
|
|
162
181
|
});
|
|
163
182
|
|
package/lib/requirements.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE }
|
|
|
19
19
|
import type { WebSearchProviderEvent } from "./events.ts";
|
|
20
20
|
import { pdfGuidance } from "./pdf.ts";
|
|
21
21
|
import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
|
|
22
|
-
|
|
23
22
|
/** How a check came out. Only `missing` is worth interrupting a session for. */
|
|
24
23
|
export type FindingSeverity = "ok" | "missing" | "degraded" | "info";
|
|
25
24
|
|
|
@@ -216,7 +215,6 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
216
215
|
notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
|
|
217
216
|
},
|
|
218
217
|
plan: {
|
|
219
|
-
optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
|
|
220
218
|
notes: ["Needs an interactive UI to enter plan mode."],
|
|
221
219
|
},
|
|
222
220
|
subagent: {
|
|
@@ -267,6 +265,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
267
265
|
optional: [gitCheck("project-scope memory under the repository root")],
|
|
268
266
|
},
|
|
269
267
|
context: {},
|
|
268
|
+
"session-bus": {},
|
|
270
269
|
wechat: {
|
|
271
270
|
optional: [
|
|
272
271
|
{
|
package/lib/tui/README.md
CHANGED
|
@@ -35,8 +35,8 @@ Reach for a `lib/tui/` primitive before composing your own: `plainRule` /
|
|
|
35
35
|
`fitCells` for two columns, `LinePane` for a scrollable log, `ListCursor` /
|
|
36
36
|
`IdCursor` for selection, `byRunningFirst` for a live list's order,
|
|
37
37
|
`DockListScreen` for a list that opens a pane, `TailResult` for a tool result
|
|
38
|
-
that lists rows or tails output, `screenFooter` for a
|
|
39
|
-
rows, and `ConfirmState` before a destructive action.
|
|
38
|
+
that lists rows, heads several row sections, or tails output, `screenFooter` for a
|
|
39
|
+
screen's own last three rows, and `ConfirmState` before a destructive action.
|
|
40
40
|
|
|
41
41
|
## Docked list screens
|
|
42
42
|
|
|
@@ -81,6 +81,20 @@ what a plain list wants — do not hand-roll the frame to get one of them:
|
|
|
81
81
|
(`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
|
|
82
82
|
else falls through to the pane's scroll keys.
|
|
83
83
|
|
|
84
|
+
A pane is a log by default: it opens at the tail and its subtitle says
|
|
85
|
+
`following` or `paused`. Two more fields cover the other body:
|
|
86
|
+
|
|
87
|
+
- `DetailContents.text` — prose instead of `lines`, wrapped to the width the pane
|
|
88
|
+
renders at and re-wrapped on a resize. One source line is one paragraph; a blank
|
|
89
|
+
line stays blank. `lines` remains the right shape for output that is already
|
|
90
|
+
split.
|
|
91
|
+
- `DetailContents.tail: false` — a prose or list pane opens at the top and never
|
|
92
|
+
claims to follow anything. Leave it out for a log.
|
|
93
|
+
|
|
94
|
+
A screen whose list is one of several scopes overrides `headerLabel()` (the
|
|
95
|
+
header rule's name) and `emptyLabel()` (the empty-list line), so `Tab` can change
|
|
96
|
+
what is listed without a second renderer; both default to the screen's options.
|
|
97
|
+
|
|
84
98
|
A destructive action asks through [`confirm.ts`](./confirm.ts):
|
|
85
99
|
`ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
|
|
86
100
|
`y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
|
package/lib/tui/dock-screen.ts
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
23
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
23
24
|
import { detailBlockRows, dockListFrame, screenHint, type ScreenHint, type ViewportRowsSource } from "./chrome.ts";
|
|
24
25
|
import { clip, fitCells } from "./columns.ts";
|
|
25
26
|
import { ConfirmState } from "./confirm.ts";
|
|
26
27
|
import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "./cursor.ts";
|
|
27
28
|
import { SEPARATORS } from "./glyphs.ts";
|
|
29
|
+
import { BODY_INDENT } from "./indent.ts";
|
|
28
30
|
import { LinePane } from "./line-pane.ts";
|
|
29
31
|
import { emptyState } from "./rows.ts";
|
|
30
32
|
import { titleRule } from "./rules.ts";
|
|
@@ -43,12 +45,23 @@ const DETAIL_CHROME_ROWS = 6;
|
|
|
43
45
|
export interface DetailContents {
|
|
44
46
|
/** Pane title, e.g. `j1 build`. */
|
|
45
47
|
title: string;
|
|
46
|
-
/** The body lines, already sanitized by the caller. */
|
|
47
|
-
lines
|
|
48
|
+
/** The body lines, already sanitized by the caller. Omit for a `text` pane. */
|
|
49
|
+
lines?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Prose body, built once at open and wrapped to the render width on every
|
|
52
|
+
* frame. Takes precedence over `lines`. A resize re-wraps; a blank line in the
|
|
53
|
+
* source stays a blank line in the pane.
|
|
54
|
+
*/
|
|
55
|
+
text?: string;
|
|
48
56
|
/** More lines exist above the ones supplied. */
|
|
49
57
|
more?: boolean;
|
|
50
58
|
/** Subtitle under the title, e.g. the log file path. */
|
|
51
59
|
subtitle?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the pane follows its tail. Default true, which is a log's behavior;
|
|
62
|
+
* false opens a prose or list pane at the top and never says `following`.
|
|
63
|
+
*/
|
|
64
|
+
tail?: boolean;
|
|
52
65
|
/**
|
|
53
66
|
* Rows above the scrollable body that depend on the width: key/value facts,
|
|
54
67
|
* a legend, a divider. Read fresh every frame with the live width and already
|
|
@@ -101,6 +114,9 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
101
114
|
private readonly confirmState = new ConfirmState();
|
|
102
115
|
private feedback: string | undefined;
|
|
103
116
|
private detailFacts: ((width: number) => string[]) | undefined;
|
|
117
|
+
/** Prose body for the open pane, wrapped to the width it is rendered at. */
|
|
118
|
+
private detailText: string | undefined;
|
|
119
|
+
private detailWrap: { width: number; lines: string[] } | undefined;
|
|
104
120
|
|
|
105
121
|
constructor(protected readonly options: DockListScreenOptions) {}
|
|
106
122
|
|
|
@@ -163,6 +179,19 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
163
179
|
this.options.requestRender();
|
|
164
180
|
}
|
|
165
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The header rule's label. A screen whose list is one of several scopes
|
|
184
|
+
* overrides it, so the rule names what is on screen.
|
|
185
|
+
*/
|
|
186
|
+
protected headerLabel(): string {
|
|
187
|
+
return this.options.title;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The empty-list line; a screen whose scopes differ overrides it. */
|
|
191
|
+
protected emptyLabel(): string {
|
|
192
|
+
return this.options.emptyList;
|
|
193
|
+
}
|
|
194
|
+
|
|
166
195
|
/** Show one line of feedback in place of the hints. */
|
|
167
196
|
protected notify(text: string): void {
|
|
168
197
|
this.feedback = text;
|
|
@@ -182,6 +211,12 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
182
211
|
// Facts stay with the pane unless this update supplies its own, so a poll that
|
|
183
212
|
// only refreshes the body cannot drop the facts the row opened with.
|
|
184
213
|
if (contents.facts) this.detailFacts = contents.facts;
|
|
214
|
+
// `text` and `tail` belong to the row that opened the pane; an update either
|
|
215
|
+
// replaces the prose or settles the pane back onto a line buffer.
|
|
216
|
+
if (contents.text !== undefined || contents.lines !== undefined) {
|
|
217
|
+
this.detailText = contents.text;
|
|
218
|
+
this.detailWrap = undefined;
|
|
219
|
+
}
|
|
185
220
|
const changed =
|
|
186
221
|
contents.subtitle === undefined
|
|
187
222
|
? this.pane.update({ title: contents.title, lines: contents.lines, more: contents.more })
|
|
@@ -201,6 +236,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
201
236
|
|
|
202
237
|
/** Re-read the open detail pane, so a poll cannot show stale lines. */
|
|
203
238
|
refreshDetail(id: string): void {
|
|
239
|
+
if (this.detailText !== undefined) return;
|
|
204
240
|
const source = this.detailById?.(id);
|
|
205
241
|
if (source && this.pane.update({ lines: source.lines, more: source.more })) this.options.requestRender();
|
|
206
242
|
}
|
|
@@ -242,10 +278,30 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
242
278
|
const contents = this.detailFor(row);
|
|
243
279
|
this.mode = "detail";
|
|
244
280
|
this.detailFacts = contents.facts;
|
|
245
|
-
this.
|
|
281
|
+
this.detailText = contents.text;
|
|
282
|
+
this.detailWrap = undefined;
|
|
283
|
+
this.pane.open(
|
|
284
|
+
this.idOf(row),
|
|
285
|
+
{
|
|
286
|
+
title: contents.title,
|
|
287
|
+
lines: contents.text === undefined ? (contents.lines ?? []) : [],
|
|
288
|
+
more: contents.more,
|
|
289
|
+
subtitle: contents.subtitle,
|
|
290
|
+
},
|
|
291
|
+
contents.tail ?? true,
|
|
292
|
+
);
|
|
246
293
|
this.options.requestRender();
|
|
247
294
|
}
|
|
248
295
|
|
|
296
|
+
/** Leave detail mode and drop everything that belonged to the open row. */
|
|
297
|
+
private closeDetail(): void {
|
|
298
|
+
this.mode = "list";
|
|
299
|
+
this.pane.close();
|
|
300
|
+
this.detailFacts = undefined;
|
|
301
|
+
this.detailText = undefined;
|
|
302
|
+
this.detailWrap = undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
249
305
|
private handleListKey(data: string): void {
|
|
250
306
|
if (isCloseKey(data) || data === "q") {
|
|
251
307
|
this.options.onClose();
|
|
@@ -294,9 +350,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
294
350
|
|
|
295
351
|
private handleDetailKey(data: string): void {
|
|
296
352
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
|
|
297
|
-
this.
|
|
298
|
-
this.pane.close();
|
|
299
|
-
this.detailFacts = undefined;
|
|
353
|
+
this.closeDetail();
|
|
300
354
|
this.options.requestRender();
|
|
301
355
|
return;
|
|
302
356
|
}
|
|
@@ -307,11 +361,11 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
307
361
|
}
|
|
308
362
|
|
|
309
363
|
/** The footer: a pending confirmation, one line of feedback, or the hints. */
|
|
310
|
-
private
|
|
364
|
+
private footer(width: number, hints: ScreenHint[]): string {
|
|
311
365
|
const pending = this.confirmState.pending;
|
|
312
366
|
if (pending) return clip(` ${paint(this.theme, "warn", pending.prompt)}`, width);
|
|
313
367
|
if (this.feedback) return clip(` ${paint(this.theme, "meta", this.feedback)}`, width);
|
|
314
|
-
return screenHint(this.theme, width,
|
|
368
|
+
return screenHint(this.theme, width, hints);
|
|
315
369
|
}
|
|
316
370
|
|
|
317
371
|
private summary(rows: readonly T[], width: number): string {
|
|
@@ -343,7 +397,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
343
397
|
const body: string[] = [];
|
|
344
398
|
let range: string | undefined;
|
|
345
399
|
if (rows.length === 0) {
|
|
346
|
-
body.push(...emptyState(this.theme, w, { label: this.
|
|
400
|
+
body.push(...emptyState(this.theme, w, { label: this.emptyLabel() }));
|
|
347
401
|
} else {
|
|
348
402
|
const end = Math.min(rows.length, this.cursor.scroll + this.cursor.visible);
|
|
349
403
|
for (let i = this.cursor.scroll; i < end; i++) body.push(this.rowLine(rows[i], w, i === index));
|
|
@@ -355,12 +409,12 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
355
409
|
return dockListFrame({
|
|
356
410
|
theme: this.theme,
|
|
357
411
|
width: w,
|
|
358
|
-
header: titleRule(this.theme, w, this.
|
|
412
|
+
header: titleRule(this.theme, w, this.headerLabel()),
|
|
359
413
|
summary: this.summary(rows, w),
|
|
360
414
|
body,
|
|
361
415
|
range,
|
|
362
416
|
detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
|
|
363
|
-
hint: this.
|
|
417
|
+
hint: this.footer(w, this.listHints(rows)),
|
|
364
418
|
});
|
|
365
419
|
}
|
|
366
420
|
|
|
@@ -368,6 +422,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
368
422
|
this.syncDetail();
|
|
369
423
|
// A live pane whose row is gone has already closed itself: draw the list.
|
|
370
424
|
if (this.mode !== "detail") return this.renderList(w);
|
|
425
|
+
this.syncText(w);
|
|
371
426
|
const facts = this.detailFacts ? this.detailFacts(w) : [];
|
|
372
427
|
const visible = fitRows(
|
|
373
428
|
this.options.viewportRowsSource,
|
|
@@ -397,14 +452,15 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
397
452
|
summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
|
|
398
453
|
body,
|
|
399
454
|
range,
|
|
400
|
-
hint:
|
|
455
|
+
hint: this.footer(w, this.detailHints(this.detailRow)),
|
|
401
456
|
});
|
|
402
457
|
}
|
|
403
458
|
|
|
404
459
|
/**
|
|
405
460
|
* Re-read a live pane in place. This runs inside a render, so it must not ask
|
|
406
461
|
* for another frame; a source that disappeared closes the pane instead of
|
|
407
|
-
* showing lines the list no longer has a row for.
|
|
462
|
+
* showing lines the list no longer has a row for. A prose pane is skipped here:
|
|
463
|
+
* `syncText` owns its lines, and this still closes it when the row is gone.
|
|
408
464
|
*/
|
|
409
465
|
private syncDetail(): void {
|
|
410
466
|
if (!this.liveDetail()) return;
|
|
@@ -412,11 +468,23 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
412
468
|
if (!id) return;
|
|
413
469
|
const source = this.detailById?.(id);
|
|
414
470
|
if (!source) {
|
|
415
|
-
this.
|
|
416
|
-
this.pane.close();
|
|
417
|
-
this.detailFacts = undefined;
|
|
471
|
+
this.closeDetail();
|
|
418
472
|
return;
|
|
419
473
|
}
|
|
420
|
-
this.pane.update({ lines: source.lines, more: source.more });
|
|
474
|
+
if (this.detailText === undefined) this.pane.update({ lines: source.lines, more: source.more });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Wrap a prose body to the width the pane actually renders at, once per width.
|
|
479
|
+
* The pane's body lines are built at open, so this is the one source that can
|
|
480
|
+
* follow a resize.
|
|
481
|
+
*/
|
|
482
|
+
private syncText(width: number): void {
|
|
483
|
+
if (this.detailText === undefined) return;
|
|
484
|
+
const inner = Math.max(1, width - BODY_INDENT);
|
|
485
|
+
if (this.detailWrap?.width === inner) return;
|
|
486
|
+
const lines = this.detailText.split("\n").flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, inner)));
|
|
487
|
+
this.detailWrap = { width: inner, lines };
|
|
488
|
+
this.pane.update({ lines });
|
|
421
489
|
}
|
|
422
490
|
}
|
package/lib/tui/line-pane.ts
CHANGED
|
@@ -45,6 +45,11 @@ export class LinePane {
|
|
|
45
45
|
visible = 1;
|
|
46
46
|
/** Whether the view is parked at the tail; set false once the user scrolls up. */
|
|
47
47
|
follow = true;
|
|
48
|
+
/**
|
|
49
|
+
* Whether this pane follows its tail. A log does; a prose or list pane does not:
|
|
50
|
+
* it opens at the top and never reports `following`/`paused`.
|
|
51
|
+
*/
|
|
52
|
+
tail = true;
|
|
48
53
|
|
|
49
54
|
private buffer: string[] = [];
|
|
50
55
|
private label = "";
|
|
@@ -66,16 +71,21 @@ export class LinePane {
|
|
|
66
71
|
return Math.max(0, this.buffer.length - this.visible);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
/** Open the pane on a row, reset to the tail. */
|
|
70
|
-
open(id: string, source: PaneSource): void {
|
|
74
|
+
/** Open the pane on a row, reset to the tail for a log, or to the top for prose. */
|
|
75
|
+
open(id: string, source: PaneSource, tail = true): void {
|
|
71
76
|
this.id = id;
|
|
72
77
|
this.buffer = [];
|
|
73
78
|
this.label = "";
|
|
74
79
|
this.subtitle = undefined;
|
|
75
80
|
this.more = false;
|
|
76
81
|
this.scroll = 0;
|
|
77
|
-
this.
|
|
82
|
+
this.tail = tail;
|
|
83
|
+
this.follow = tail;
|
|
78
84
|
this.update(source);
|
|
85
|
+
if (!tail) {
|
|
86
|
+
this.follow = false;
|
|
87
|
+
this.scroll = 0;
|
|
88
|
+
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/** Close the pane and drop its buffer. */
|
|
@@ -87,6 +97,7 @@ export class LinePane {
|
|
|
87
97
|
this.more = false;
|
|
88
98
|
this.scroll = 0;
|
|
89
99
|
this.follow = true;
|
|
100
|
+
this.tail = true;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
/**
|
|
@@ -127,7 +138,7 @@ export class LinePane {
|
|
|
127
138
|
const parts: string[] = [];
|
|
128
139
|
if (this.subtitle) parts.push(this.subtitle);
|
|
129
140
|
if (this.more && this.buffer.length > 0) parts.push(`showing last ${this.buffer.length} lines`);
|
|
130
|
-
if (this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
|
|
141
|
+
if (this.tail && this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
|
|
131
142
|
return parts.join(" · ");
|
|
132
143
|
}
|
|
133
144
|
|
package/lib/tui/tool.ts
CHANGED
|
@@ -200,18 +200,29 @@ export function earlierHint(theme: Theme, hidden: number, label: string): string
|
|
|
200
200
|
/** The row renderer a {@link TailResult} list uses: the extension's row grammar at a width. */
|
|
201
201
|
export type TailRowLine<Row> = (row: Row, width: number, theme: Theme) => string;
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/** One group of rows inside a {@link TailResult}, under an optional faint heading. */
|
|
204
|
+
export interface TailSection<Row> {
|
|
205
|
+
/** A faint heading above the rows, e.g. `Claims`; omit for the block's primary list. */
|
|
206
|
+
label?: string;
|
|
207
|
+
rows: readonly Row[];
|
|
208
|
+
more: number;
|
|
209
|
+
rowLine: TailRowLine<Row>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** What a {@link TailResult} draws: the caller's rows plus a hidden count, a headed row list, or a bounded tail. */
|
|
204
213
|
export type TailResultInput<Row> =
|
|
205
214
|
| { kind: "list"; rows: readonly Row[]; more: number; rowLine: TailRowLine<Row> }
|
|
215
|
+
| { kind: "sections"; sections: readonly TailSection<Row>[] }
|
|
206
216
|
| { kind: "lines"; lines: readonly string[]; earlier: number; earlierLabel: string };
|
|
207
217
|
|
|
208
218
|
/**
|
|
209
219
|
* The transcript result block a tool that lists rows or tails output shares.
|
|
210
220
|
*
|
|
211
221
|
* One leading blank line per the block grammar, then either the caller's rows
|
|
212
|
-
* (already ordered and capped) with a "more" note,
|
|
213
|
-
* "earlier" note. The caller owns the row
|
|
214
|
-
* grammar — a peer-session tag, a status
|
|
222
|
+
* (already ordered and capped) with a "more" note, one headed group of rows per
|
|
223
|
+
* section, or the themed tail with an "earlier" note. The caller owns the row
|
|
224
|
+
* renderer, so the extension's own row grammar — a peer-session tag, a status
|
|
225
|
+
* glyph — stays with its vocabulary.
|
|
215
226
|
*/
|
|
216
227
|
export class TailResult<Row> implements Component {
|
|
217
228
|
private input: TailResultInput<Row>;
|
|
@@ -239,6 +250,14 @@ export class TailResult<Row> implements Component {
|
|
|
239
250
|
if (input.more > 0) lines.push(clip(` ${overflowHint(this.theme, input.more)}`, w));
|
|
240
251
|
return lines;
|
|
241
252
|
}
|
|
253
|
+
if (input.kind === "sections") {
|
|
254
|
+
for (const section of input.sections) {
|
|
255
|
+
if (section.label) lines.push(clip(paint(this.theme, "faint", section.label), w));
|
|
256
|
+
for (const row of section.rows) lines.push(section.rowLine(row, w, this.theme));
|
|
257
|
+
if (section.more > 0) lines.push(clip(` ${overflowHint(this.theme, section.more)}`, w));
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
242
261
|
if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
|
|
243
262
|
for (const line of input.lines) lines.push(this.theme.fg("toolOutput", clip(line, w)));
|
|
244
263
|
return lines;
|
package/lib/web/components.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
|
|
|
60
60
|
.bf-dialog mark{background:transparent;color:var(--warn);font-weight:var(--fw-semibold)}
|
|
61
61
|
.bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
|
|
62
62
|
.bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
|
|
63
|
-
.bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed)}
|
|
63
|
+
.bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed);overflow:auto;min-height:0}
|
|
64
64
|
.bf-dialog__body p{margin:0 0 var(--space-2)}
|
|
65
65
|
.bf-dialog__body p:last-child{margin:0}
|
|
66
66
|
.bf-dialog__hint{color:var(--muted)}
|
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, file-read, check, memory, context, wechat, and bytefly.",
|
|
3
|
+
"version": "0.18.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, session-bus, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"./extensions/file-open/index.ts",
|
|
102
102
|
"./extensions/memory/index.ts",
|
|
103
103
|
"./extensions/context/index.ts",
|
|
104
|
+
"./extensions/session-bus/index.ts",
|
|
104
105
|
"./extensions/wechat/index.ts",
|
|
105
106
|
"./extensions/bytefly/index.ts"
|
|
106
107
|
],
|