@gavin-hu/my-pi-agent 0.11.2 → 0.11.4
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/extensions/wechat/README.md +33 -9
- package/extensions/wechat/bridge.ts +143 -23
- package/extensions/wechat/commands.ts +162 -4
- package/extensions/wechat/deliver.ts +16 -2
- package/extensions/wechat/doctor.ts +122 -0
- package/extensions/wechat/index.ts +7 -2
- package/extensions/wechat/interaction.ts +38 -1
- package/extensions/wechat/lock.ts +11 -2
- package/extensions/wechat/peer-worker.ts +10 -1
- package/extensions/wechat/poll.ts +10 -0
- package/extensions/wechat/status-screen.ts +224 -33
- package/package.json +1 -1
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `connectionOf`, `statusView`, `statusLine`, and `statusBody` are pure, so the
|
|
5
5
|
* wording, fields, and width safety are unit-tested; `StatusScreenComponent` is
|
|
6
|
-
* the TUI mounted by `runPanel`. It is the control hub:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* the TUI mounted by `runPanel`. It is the control hub: the global keys (login,
|
|
7
|
+
* new session, logout) close the screen and dispatch, while a focused peer row
|
|
8
|
+
* can be stopped, reset, or opened in `/wechat history` without leaving the
|
|
9
|
+
* screen.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { Component } from "@earendil-works/pi-tui";
|
|
14
|
+
import { formatDuration, sanitizeLine } from "../../lib/format.ts";
|
|
14
15
|
import { screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
|
|
15
16
|
import { clip } from "../../lib/tui/columns.ts";
|
|
16
|
-
import { isCloseKey } from "../../lib/tui/cursor.ts";
|
|
17
|
+
import { formatRange, IdCursor, isCloseKey, navIntent } from "../../lib/tui/cursor.ts";
|
|
17
18
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
18
|
-
import { keyValueRow } from "../../lib/tui/rows.ts";
|
|
19
|
+
import { keyValueRow, listRow } from "../../lib/tui/rows.ts";
|
|
19
20
|
import { titleRule } from "../../lib/tui/rules.ts";
|
|
20
21
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
22
|
import type { BridgeStatus } from "./bridge.ts";
|
|
@@ -23,10 +24,13 @@ import { peerLabel } from "./format.ts";
|
|
|
23
24
|
import type { BotCredentials } from "./types.ts";
|
|
24
25
|
|
|
25
26
|
/** User-facing connection word; the internal `BridgeState` stays running/stopped. */
|
|
26
|
-
export type StatusConnection = "open" | "expired" | "closed";
|
|
27
|
+
export type StatusConnection = "open" | "expired" | "paused" | "closed";
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* What the status screen closed with: a global action, a peer history request,
|
|
31
|
+
* or `close`.
|
|
32
|
+
*/
|
|
33
|
+
export type StatusAction = "close" | "login" | "new" | "logout" | { kind: "history"; peer: string } | { kind: "allow" };
|
|
30
34
|
|
|
31
35
|
/** The facts the status screen and the one-line summary render. */
|
|
32
36
|
export interface StatusView {
|
|
@@ -44,26 +48,36 @@ export interface StatusView {
|
|
|
44
48
|
refusedPeers: string[];
|
|
45
49
|
mediaDropped: number;
|
|
46
50
|
promptPending: boolean;
|
|
51
|
+
/** The waiting prompt's peer, title, and start time. */
|
|
52
|
+
prompt?: { peer: string; title: string; startedAt: number };
|
|
53
|
+
/** The clock the view was built with, for the prompt age. */
|
|
54
|
+
now?: number;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/** One peer's line in the control screen. */
|
|
50
58
|
export interface PeerRow {
|
|
59
|
+
/** The raw peer id, so an action targets the right person; falls back to `label`. */
|
|
60
|
+
id?: string;
|
|
51
61
|
label: string;
|
|
52
62
|
status: "idle" | "queued" | "running";
|
|
53
63
|
queued: number;
|
|
54
64
|
runningMs?: number;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
/** How many peer rows the screen lists before
|
|
67
|
+
/** How many peer rows the screen lists before scrolling. */
|
|
58
68
|
const MAX_PEER_ROWS = 5;
|
|
59
69
|
|
|
60
70
|
/** The connection word for a bridge status, or `closed` when there is none. */
|
|
61
71
|
export function connectionOf(status: BridgeStatus | undefined): StatusConnection {
|
|
62
|
-
return status?.open ? "open" : status?.expired ? "expired" : "closed";
|
|
72
|
+
return status?.paused ? "paused" : status?.open ? "open" : status?.expired ? "expired" : "closed";
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/** Build the view from the stored account and the live bridge status. */
|
|
66
|
-
export function statusView(
|
|
76
|
+
export function statusView(
|
|
77
|
+
account: BotCredentials | undefined,
|
|
78
|
+
status: BridgeStatus | undefined,
|
|
79
|
+
now: number = Date.now(),
|
|
80
|
+
): StatusView {
|
|
67
81
|
return {
|
|
68
82
|
bridge: status !== undefined,
|
|
69
83
|
account: account ? peerLabel(account.ilinkUserId) : undefined,
|
|
@@ -72,6 +86,7 @@ export function statusView(account: BotCredentials | undefined, status: BridgeSt
|
|
|
72
86
|
peers: status?.peerCount ?? 0,
|
|
73
87
|
queued: status?.queued ?? 0,
|
|
74
88
|
peerRows: (status?.peerStates ?? []).map((entry) => ({
|
|
89
|
+
id: entry.peer,
|
|
75
90
|
label: peerLabel(entry.peer),
|
|
76
91
|
status: entry.state.status,
|
|
77
92
|
queued: entry.state.queued,
|
|
@@ -81,6 +96,14 @@ export function statusView(account: BotCredentials | undefined, status: BridgeSt
|
|
|
81
96
|
refusedPeers: status?.refusedPeers ?? [],
|
|
82
97
|
mediaDropped: status?.mediaDropped ?? 0,
|
|
83
98
|
promptPending: status?.promptPending ?? false,
|
|
99
|
+
prompt: status?.prompt
|
|
100
|
+
? {
|
|
101
|
+
peer: sanitizeLine(peerLabel(status.prompt.peer)),
|
|
102
|
+
title: sanitizeLine(status.prompt.title),
|
|
103
|
+
startedAt: status.prompt.startedAt,
|
|
104
|
+
}
|
|
105
|
+
: undefined,
|
|
106
|
+
now,
|
|
84
107
|
};
|
|
85
108
|
}
|
|
86
109
|
|
|
@@ -123,6 +146,8 @@ function connectionText(connection: StatusConnection): string {
|
|
|
123
146
|
return "open";
|
|
124
147
|
case "expired":
|
|
125
148
|
return "expired";
|
|
149
|
+
case "paused":
|
|
150
|
+
return "paused";
|
|
126
151
|
case "closed":
|
|
127
152
|
return "closed";
|
|
128
153
|
}
|
|
@@ -135,13 +160,21 @@ function connectionRole(connection: StatusConnection): TuiRole {
|
|
|
135
160
|
return "ok";
|
|
136
161
|
case "expired":
|
|
137
162
|
return "bad";
|
|
163
|
+
case "paused":
|
|
164
|
+
return "warn";
|
|
138
165
|
case "closed":
|
|
139
166
|
return "meta";
|
|
140
167
|
}
|
|
141
168
|
}
|
|
142
169
|
|
|
170
|
+
/** The peer focus passed into the body: which row is selected and which slice shows. */
|
|
171
|
+
export interface PeerSelection {
|
|
172
|
+
selectedId?: string;
|
|
173
|
+
window?: { start: number; end: number };
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
/** Rows for the status screen body, each fitted to `width`. */
|
|
144
|
-
export function statusBody(view: StatusView, theme: Theme, width: number): string[] {
|
|
177
|
+
export function statusBody(view: StatusView, theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
145
178
|
const target = Math.max(1, width);
|
|
146
179
|
const lines = [
|
|
147
180
|
row(theme, target, "Account", view.account ?? "not logged in", view.account ? "label" : "meta"),
|
|
@@ -153,12 +186,20 @@ export function statusBody(view: StatusView, theme: Theme, width: number): strin
|
|
|
153
186
|
lines.push(row(theme, target, "Queued", String(view.queued), "meta"));
|
|
154
187
|
if (view.refused > 0) lines.push(row(theme, target, "Refused", refusedValue(view), "meta"));
|
|
155
188
|
if (view.mediaDropped > 0) lines.push(row(theme, target, "Media", `${view.mediaDropped} dropped`, "meta"));
|
|
156
|
-
if (view.
|
|
157
|
-
|
|
189
|
+
if (view.prompt) {
|
|
190
|
+
const age = formatDuration(Math.max(0, (view.now ?? Date.now()) - view.prompt.startedAt));
|
|
191
|
+
lines.push(row(theme, target, "Prompt", `${view.prompt.peer} · ${view.prompt.title} · ${age}`, "accent"));
|
|
192
|
+
} else if (view.promptPending) {
|
|
193
|
+
lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
|
|
194
|
+
}
|
|
195
|
+
lines.push(...peerLines(view.peerRows, theme, target, selection));
|
|
158
196
|
}
|
|
159
197
|
if (view.connection === "expired") {
|
|
160
198
|
lines.push("");
|
|
161
199
|
lines.push(clip(` ${paint(theme, "bad", "Session expired — press l to log in again.")}`, target));
|
|
200
|
+
} else if (view.connection === "paused") {
|
|
201
|
+
lines.push("");
|
|
202
|
+
lines.push(clip(` ${paint(theme, "warn", "Paused — press p to resume polling.")}`, target));
|
|
162
203
|
} else if (view.connection === "closed" && view.account) {
|
|
163
204
|
lines.push("");
|
|
164
205
|
lines.push(clip(` ${paint(theme, "meta", "Not connected — press l to log in again.")}`, target));
|
|
@@ -179,47 +220,100 @@ function peerValue(row: PeerRow): string {
|
|
|
179
220
|
return "idle";
|
|
180
221
|
}
|
|
181
222
|
|
|
182
|
-
/** One
|
|
183
|
-
function peerLines(rows: PeerRow[], theme: Theme, width: number): string[] {
|
|
223
|
+
/** One selectable line per peer, with a range row when the list is scrolled. */
|
|
224
|
+
function peerLines(rows: PeerRow[], theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
184
225
|
if (rows.length === 0) return [];
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
226
|
+
const start = selection?.window?.start ?? 0;
|
|
227
|
+
const end = selection?.window?.end ?? Math.min(rows.length, MAX_PEER_ROWS);
|
|
228
|
+
const shown = rows.slice(start, end);
|
|
229
|
+
const lines = ["", clip(` ${paint(theme, "meta", "Conversations")}`, width)];
|
|
230
|
+
for (const entry of shown) {
|
|
231
|
+
const selected = selection?.selectedId !== undefined && (entry.id ?? entry.label) === selection.selectedId;
|
|
189
232
|
const role: TuiRole = entry.status === "running" ? "accent" : entry.status === "queued" ? "warn" : "meta";
|
|
190
|
-
lines.push(
|
|
233
|
+
lines.push(
|
|
234
|
+
listRow(theme, width, {
|
|
235
|
+
indent: 2,
|
|
236
|
+
selected,
|
|
237
|
+
label: entry.label,
|
|
238
|
+
trailing: peerValue(entry),
|
|
239
|
+
trailingRole: role,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
191
242
|
}
|
|
192
|
-
if (
|
|
243
|
+
if (selection?.window !== undefined) {
|
|
244
|
+
if (start > 0 || end < rows.length) {
|
|
245
|
+
lines.push(formatRange(theme, width, { start, end, total: rows.length }));
|
|
246
|
+
}
|
|
247
|
+
} else if (rows.length > shown.length) {
|
|
193
248
|
lines.push(clip(` ${paint(theme, "meta", `+${rows.length - shown.length} more`)}`, width));
|
|
194
249
|
}
|
|
195
250
|
return lines;
|
|
196
251
|
}
|
|
197
252
|
|
|
253
|
+
/** The peer actions the screen can run without closing. */
|
|
254
|
+
export interface StatusActions {
|
|
255
|
+
/** Stop the peer's running turn; returns the line to flash. */
|
|
256
|
+
stop(peer: string): string;
|
|
257
|
+
/** Start a fresh conversation for the peer; returns the line to flash. */
|
|
258
|
+
reset(peer: string): string;
|
|
259
|
+
/** Withdraw the waiting prompt; returns the line to flash. */
|
|
260
|
+
cancelPrompt(): string;
|
|
261
|
+
/** Pause or resume polling; async because it settles the poll loop first. */
|
|
262
|
+
togglePause(): string | Promise<string>;
|
|
263
|
+
}
|
|
264
|
+
|
|
198
265
|
export interface StatusScreenOptions {
|
|
199
266
|
theme: Theme;
|
|
200
267
|
/** Live view, re-read on every render so `r` shows the current state. */
|
|
201
268
|
view: () => StatusView;
|
|
202
269
|
/** Called once when the screen closes, with the chosen action. */
|
|
203
270
|
onClose: (action: StatusAction) => void;
|
|
271
|
+
/** In-place peer actions; absent makes `s`/`d` explain that they are unavailable. */
|
|
272
|
+
actions?: StatusActions;
|
|
204
273
|
/** Repaint after a state change. */
|
|
205
274
|
requestRender: () => void;
|
|
206
275
|
}
|
|
207
276
|
|
|
208
277
|
/** The `/wechat` control screen: live connection facts plus every action. */
|
|
209
278
|
export class StatusScreenComponent implements Component {
|
|
279
|
+
private readonly cursor = new IdCursor();
|
|
280
|
+
private flash: string | undefined;
|
|
281
|
+
|
|
210
282
|
constructor(private readonly options: StatusScreenOptions) {}
|
|
211
283
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
/**
|
|
285
|
+
* The focused peer, or undefined when the list is empty. Not named `focused`:
|
|
286
|
+
* the host duck-types focusables with `"focused" in component` and writes a
|
|
287
|
+
* boolean there, which would shadow the method.
|
|
288
|
+
*/
|
|
289
|
+
private focusedPeer(view: StatusView): PeerRow | undefined {
|
|
290
|
+
const rows = view.peerRows;
|
|
291
|
+
if (rows.length === 0) return undefined;
|
|
292
|
+
return rows[this.cursor.indexOf(rows, (row) => row.id ?? row.label)];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private setFlash(message: string | undefined): void {
|
|
296
|
+
this.flash = message;
|
|
297
|
+
this.options.requestRender();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private hints(view: StatusView): ScreenHint[] {
|
|
301
|
+
const hints: ScreenHint[] = ["Esc close"];
|
|
302
|
+
if (view.peerRows.length > 0) {
|
|
303
|
+
hints.push("j/k move", { text: "s stop", color: "accent" }, { text: "d reset", color: "warning" }, "h history");
|
|
304
|
+
}
|
|
305
|
+
hints.push(
|
|
215
306
|
{ text: "l login", color: "accent" },
|
|
216
307
|
{ text: "n new", color: "accent" },
|
|
217
308
|
{ text: "o logout", color: "error" },
|
|
218
309
|
"r refresh",
|
|
219
|
-
|
|
310
|
+
);
|
|
311
|
+
return hints;
|
|
220
312
|
}
|
|
221
313
|
|
|
222
314
|
handleInput(data: string): void {
|
|
315
|
+
const view = this.options.view();
|
|
316
|
+
const peer = this.focusedPeer(view);
|
|
223
317
|
if (isCloseKey(data)) {
|
|
224
318
|
this.options.onClose("close");
|
|
225
319
|
return;
|
|
@@ -236,21 +330,118 @@ export class StatusScreenComponent implements Component {
|
|
|
236
330
|
this.options.onClose("logout");
|
|
237
331
|
return;
|
|
238
332
|
}
|
|
239
|
-
if (data === "r" || data === "R")
|
|
333
|
+
if (data === "r" || data === "R") {
|
|
334
|
+
this.setFlash(undefined);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (data === "p" || data === "P") {
|
|
338
|
+
if (!view.bridge) {
|
|
339
|
+
this.setFlash("WeChat is not running.");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const outcome = this.options.actions?.togglePause();
|
|
343
|
+
if (!outcome) {
|
|
344
|
+
this.setFlash("Pause is unavailable here.");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (typeof outcome === "string") {
|
|
348
|
+
this.setFlash(outcome);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.setFlash(view.connection === "paused" ? "Resuming…" : "Pausing…");
|
|
352
|
+
void outcome.then(
|
|
353
|
+
(line) => this.setFlash(line),
|
|
354
|
+
() => this.setFlash("Pause action failed."),
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (data === "a" || data === "A") {
|
|
359
|
+
this.options.onClose({ kind: "allow" });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (data === "c" || data === "C") {
|
|
363
|
+
if (!view.prompt) {
|
|
364
|
+
this.setFlash("No prompt waiting.");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.setFlash(this.options.actions?.cancelPrompt() ?? "Prompt cancel is unavailable.");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (data === "s" || data === "S" || data === "d" || data === "D" || data === "h" || data === "H") {
|
|
371
|
+
if (!peer) {
|
|
372
|
+
this.setFlash("No peer conversation yet.");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const id = peer.id ?? peer.label;
|
|
376
|
+
if (data === "h" || data === "H") {
|
|
377
|
+
this.options.onClose({ kind: "history", peer: id });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const actions = this.options.actions;
|
|
381
|
+
if (!actions) {
|
|
382
|
+
this.setFlash("Peer actions are unavailable here.");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.setFlash(data === "s" || data === "S" ? actions.stop(id) : actions.reset(id));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const rows = view.peerRows;
|
|
389
|
+
const index = this.cursor.indexOf(rows, (row) => row.id ?? row.label);
|
|
390
|
+
switch (navIntent(data)) {
|
|
391
|
+
case "up":
|
|
392
|
+
this.select(index - 1, view);
|
|
393
|
+
return;
|
|
394
|
+
case "down":
|
|
395
|
+
this.select(index + 1, view);
|
|
396
|
+
return;
|
|
397
|
+
case "pageUp":
|
|
398
|
+
this.select(index - this.cursor.visible, view);
|
|
399
|
+
return;
|
|
400
|
+
case "pageDown":
|
|
401
|
+
this.select(index + this.cursor.visible, view);
|
|
402
|
+
return;
|
|
403
|
+
case "home":
|
|
404
|
+
this.select(0, view);
|
|
405
|
+
return;
|
|
406
|
+
case "end":
|
|
407
|
+
this.select(rows.length - 1, view);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
240
410
|
}
|
|
241
411
|
|
|
242
|
-
/**
|
|
412
|
+
/** Focus the row at `index` and repaint when it moves. */
|
|
413
|
+
private select(index: number, view: StatusView): void {
|
|
414
|
+
const rows = view.peerRows;
|
|
415
|
+
if (rows.length === 0) return;
|
|
416
|
+
this.cursor.select(rows, (row) => row.id ?? row.label, index);
|
|
417
|
+
this.options.requestRender();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The cursor is plain state and the view is live, so there is nothing to invalidate. */
|
|
243
421
|
invalidate(): void {}
|
|
244
422
|
|
|
245
423
|
render(width: number): string[] {
|
|
246
424
|
const target = Math.max(1, width);
|
|
247
425
|
const theme = this.options.theme;
|
|
248
|
-
|
|
426
|
+
const view = this.options.view();
|
|
427
|
+
const rows = view.peerRows;
|
|
428
|
+
this.cursor.visible = Math.min(MAX_PEER_ROWS, Math.max(1, rows.length));
|
|
429
|
+
this.cursor.clamp(rows, (row) => row.id ?? row.label);
|
|
430
|
+
const peer = this.focusedPeer(view);
|
|
431
|
+
if (rows.length > 0) this.cursor.ensureVisible(this.cursor.indexOf(rows, (row) => row.id ?? row.label));
|
|
432
|
+
const start = this.cursor.scroll;
|
|
433
|
+
const end = Math.min(rows.length, start + this.cursor.visible);
|
|
434
|
+
const selection: PeerSelection | undefined =
|
|
435
|
+
rows.length === 0
|
|
436
|
+
? undefined
|
|
437
|
+
: { selectedId: peer ? (peer.id ?? peer.label) : undefined, window: { start, end } };
|
|
438
|
+
const lines = [
|
|
249
439
|
titleRule(theme, target, `wechat${SEPARATORS.item}control`),
|
|
250
440
|
"",
|
|
251
|
-
...statusBody(
|
|
252
|
-
"",
|
|
253
|
-
screenHint(theme, target, this.hints()),
|
|
441
|
+
...statusBody(view, theme, target, selection),
|
|
254
442
|
];
|
|
443
|
+
if (this.flash) lines.push("", clip(` ${paint(theme, "meta", this.flash)}`, target));
|
|
444
|
+
lines.push("", screenHint(theme, target, this.hints(view)));
|
|
445
|
+
return lines;
|
|
255
446
|
}
|
|
256
447
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
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, memory, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|