@gavin-hu/my-pi-agent 0.11.1 → 0.11.3
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 -11
- package/extensions/wechat/admission.ts +104 -0
- package/extensions/wechat/bridge.ts +186 -626
- package/extensions/wechat/commands.ts +121 -9
- package/extensions/wechat/deliver.ts +207 -0
- package/extensions/wechat/doctor.ts +121 -0
- package/extensions/wechat/errors.ts +11 -0
- package/extensions/wechat/format.ts +1 -72
- package/extensions/wechat/inbound.ts +152 -0
- package/extensions/wechat/index.ts +6 -2
- package/extensions/wechat/interaction.ts +40 -2
- package/extensions/wechat/lock.ts +11 -2
- package/extensions/wechat/login.ts +2 -20
- package/extensions/wechat/notices.ts +76 -0
- package/extensions/wechat/peer-worker.ts +13 -17
- package/extensions/wechat/poll.ts +146 -0
- package/extensions/wechat/presence.ts +274 -0
- package/extensions/wechat/slash.ts +2 -1
- package/extensions/wechat/status-screen.ts +192 -31
- package/extensions/wechat/timer.ts +25 -5
- package/package.json +1 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signals a peer sees while their message waits: the typing indicator, the
|
|
3
|
+
* one delayed acknowledgement, the "still working" line, and the "queued behind
|
|
4
|
+
* another conversation" line.
|
|
5
|
+
*
|
|
6
|
+
* They are grouped here because they share the same per-peer timer bookkeeping:
|
|
7
|
+
* every entry is a cancellable timer, every one is cleared when the turn settles,
|
|
8
|
+
* and a reconnect drops all of them at once. WeChat notifies per bubble, so each
|
|
9
|
+
* notice is bounded to one per wait episode.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { BotCredentials } from "./types.ts";
|
|
13
|
+
import type { WechatClient } from "./client.ts";
|
|
14
|
+
import type { PeerState } from "./peer-worker.ts";
|
|
15
|
+
import type { PeerLanguage } from "./format.ts";
|
|
16
|
+
import { notice } from "./notices.ts";
|
|
17
|
+
import type { ArmTimer } from "./timer.ts";
|
|
18
|
+
|
|
19
|
+
/** How long a fetched typing ticket is reused. */
|
|
20
|
+
const TYPING_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
/** How often the typing indicator is refreshed while a peer waits. */
|
|
22
|
+
const TYPING_KEEPALIVE_MS = 10_000;
|
|
23
|
+
/** Stop refreshing the indicator after this long; a wedged turn must not tick forever. */
|
|
24
|
+
const TYPING_LIMIT_MS = 15 * 60 * 1000;
|
|
25
|
+
/** Wait this long before telling a queued peer they are queued. */
|
|
26
|
+
const ACK_DELAY_MS = 4000;
|
|
27
|
+
/** Suppress a second acknowledgement inside this window. */
|
|
28
|
+
const ACK_REPEAT_MS = 60_000;
|
|
29
|
+
/** Say the running turn is still going after this long without a reply. */
|
|
30
|
+
const TURN_NOTICE_MS = 30_000;
|
|
31
|
+
/** Say `busy` when a message is still queued behind a running turn after this. */
|
|
32
|
+
const BUSY_NOTICE_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
export interface PresenceDeps {
|
|
35
|
+
typingIndicator: () => boolean;
|
|
36
|
+
now: () => number;
|
|
37
|
+
armTimer: ArmTimer;
|
|
38
|
+
client: () => WechatClient | undefined;
|
|
39
|
+
account: () => BotCredentials | undefined;
|
|
40
|
+
signal: () => AbortSignal | undefined;
|
|
41
|
+
/** The peer's conversation state, for the wait decisions. */
|
|
42
|
+
statusOf: (peer: string) => PeerState["status"];
|
|
43
|
+
languageOf: (peer: string) => PeerLanguage;
|
|
44
|
+
/** Send one stock line to the peer (chunked by the bridge). */
|
|
45
|
+
send: (peer: string, contextToken: string | undefined, text: string) => Promise<unknown>;
|
|
46
|
+
/** A forwarded dialog is waiting; its own reminder covers the wait. */
|
|
47
|
+
promptPending: () => boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One message a notice refers to. */
|
|
51
|
+
export interface PresenceTarget {
|
|
52
|
+
peer: string;
|
|
53
|
+
contextToken?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Presence {
|
|
57
|
+
/** Start (or restart) the indicator and keep it alive while the peer waits. */
|
|
58
|
+
keepTyping(peer: string, contextToken: string | undefined): void;
|
|
59
|
+
/** Turn the indicator off once a turn settles. */
|
|
60
|
+
typingOff(peer: string): Promise<void>;
|
|
61
|
+
/** Stop the keep-alive for peers with nothing left to answer. */
|
|
62
|
+
pruneTyping(): void;
|
|
63
|
+
/** Stop one peer's keep-alive without touching the indicator. */
|
|
64
|
+
stopTyping(peer: string): void;
|
|
65
|
+
/** Tell a queued peer once that their message is waiting. */
|
|
66
|
+
armAck(target: PresenceTarget): void;
|
|
67
|
+
cancelAck(peer: string): void;
|
|
68
|
+
/** Say once that this turn is taking a while. */
|
|
69
|
+
armSlowNotice(peer: string, contextToken: string | undefined): void;
|
|
70
|
+
/** Say once why a message is still waiting behind another turn. */
|
|
71
|
+
armBusyNotice(peer: string, contextToken: string | undefined): void;
|
|
72
|
+
stopSlowNotice(peer: string): void;
|
|
73
|
+
stopBusyNotice(peer: string): void;
|
|
74
|
+
/** Drop every timer and cached ticket (a new connection or a shutdown). */
|
|
75
|
+
reset(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createPresence(deps: PresenceDeps): Presence {
|
|
79
|
+
const typingCache = new Map<string, { ticket?: string; checkedAt: number }>();
|
|
80
|
+
/** Per-peer keep-alive cancellers for the typing indicator. */
|
|
81
|
+
const typing = new Map<string, () => void>();
|
|
82
|
+
/** Per-peer pending acknowledgement cancellers. */
|
|
83
|
+
const acks = new Map<string, () => void>();
|
|
84
|
+
/** When each peer was last acknowledged, so a chatty peer is not spammed. */
|
|
85
|
+
const ackedAt = new Map<string, number>();
|
|
86
|
+
/** Peers told that a turn is still running, until that turn ends. */
|
|
87
|
+
const busyNotified = new Set<string>();
|
|
88
|
+
/** Per-peer `busy` and `stillWorking` cancellers. */
|
|
89
|
+
const busyTimers = new Map<string, () => void>();
|
|
90
|
+
const slowTimers = new Map<string, () => void>();
|
|
91
|
+
|
|
92
|
+
const hasWork = (peer: string): boolean => deps.statusOf(peer) !== "idle";
|
|
93
|
+
const isRunning = (peer: string): boolean => deps.statusOf(peer) === "running";
|
|
94
|
+
|
|
95
|
+
/** The typing ticket for a peer, fetched on first use and cached. */
|
|
96
|
+
async function typingTicket(peer: string, contextToken: string | undefined): Promise<string | undefined> {
|
|
97
|
+
const cached = typingCache.get(peer);
|
|
98
|
+
const now = deps.now();
|
|
99
|
+
if (cached && now - cached.checkedAt < TYPING_TTL_MS) return cached.ticket;
|
|
100
|
+
const client = deps.client();
|
|
101
|
+
const account = deps.account();
|
|
102
|
+
if (!client || !account) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
const response = await client.getConfig(account, { ilinkUserId: peer, contextToken }, deps.signal());
|
|
105
|
+
typingCache.set(peer, { ticket: response?.typingTicket, checkedAt: now });
|
|
106
|
+
return response?.typingTicket;
|
|
107
|
+
} catch {
|
|
108
|
+
typingCache.set(peer, { ticket: undefined, checkedAt: now });
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function typingOn(peer: string, contextToken: string | undefined): Promise<void> {
|
|
114
|
+
const client = deps.client();
|
|
115
|
+
const account = deps.account();
|
|
116
|
+
if (!deps.typingIndicator() || !client || !account) return;
|
|
117
|
+
const ticket = await typingTicket(peer, contextToken);
|
|
118
|
+
if (!ticket) return;
|
|
119
|
+
try {
|
|
120
|
+
await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: true }, deps.signal());
|
|
121
|
+
// The peer's work may have finished while the ticket round-trip was in
|
|
122
|
+
// flight; cancel immediately so the indicator cannot get stuck on.
|
|
123
|
+
if (!hasWork(peer)) {
|
|
124
|
+
await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, deps.signal());
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Typing is best-effort.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function typingOff(peer: string): Promise<void> {
|
|
132
|
+
const client = deps.client();
|
|
133
|
+
const account = deps.account();
|
|
134
|
+
if (!deps.typingIndicator() || !client || !account) return;
|
|
135
|
+
const ticket = typingCache.get(peer)?.ticket;
|
|
136
|
+
if (!ticket) return;
|
|
137
|
+
try {
|
|
138
|
+
await client.sendTyping(account, { ilinkUserId: peer, typingTicket: ticket, typing: false }, deps.signal());
|
|
139
|
+
} catch {
|
|
140
|
+
// Typing is best-effort.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stopTyping(peer: string): void {
|
|
145
|
+
const cancel = typing.get(peer);
|
|
146
|
+
if (!cancel) return;
|
|
147
|
+
typing.delete(peer);
|
|
148
|
+
cancel();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Start the typing indicator for a peer and keep it alive while they wait.
|
|
153
|
+
* The chat window is the peer's only channel, and WeChat drops the indicator
|
|
154
|
+
* after a few seconds, so a long turn needs the refresh to look alive at all.
|
|
155
|
+
*/
|
|
156
|
+
function keepTyping(peer: string, contextToken: string | undefined): void {
|
|
157
|
+
if (!deps.typingIndicator()) return;
|
|
158
|
+
stopTyping(peer);
|
|
159
|
+
void typingOn(peer, contextToken);
|
|
160
|
+
const started = deps.now();
|
|
161
|
+
const refresh = (): void => {
|
|
162
|
+
if (!hasWork(peer) || deps.now() - started >= TYPING_LIMIT_MS) {
|
|
163
|
+
typing.delete(peer);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
void typingOn(peer, contextToken);
|
|
167
|
+
typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh));
|
|
168
|
+
};
|
|
169
|
+
typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stopSlowNotice(peer: string): void {
|
|
173
|
+
const cancel = slowTimers.get(peer);
|
|
174
|
+
if (cancel) cancel();
|
|
175
|
+
slowTimers.delete(peer);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stopBusyNotice(peer: string): void {
|
|
179
|
+
const cancel = busyTimers.get(peer);
|
|
180
|
+
if (cancel) cancel();
|
|
181
|
+
busyTimers.delete(peer);
|
|
182
|
+
busyNotified.delete(peer);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function armSlowNotice(peer: string, contextToken: string | undefined): void {
|
|
186
|
+
stopSlowNotice(peer);
|
|
187
|
+
slowTimers.set(
|
|
188
|
+
peer,
|
|
189
|
+
deps.armTimer(TURN_NOTICE_MS, () => {
|
|
190
|
+
slowTimers.delete(peer);
|
|
191
|
+
if (!isRunning(peer)) return;
|
|
192
|
+
// A turn waiting on an unanswered dialog is not "still working": the
|
|
193
|
+
// prompt itself nudges, and saying both reads as noise.
|
|
194
|
+
if (deps.promptPending()) return;
|
|
195
|
+
void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function armBusyNotice(peer: string, contextToken: string | undefined): void {
|
|
201
|
+
if (busyNotified.has(peer) || busyTimers.has(peer)) return;
|
|
202
|
+
busyTimers.set(
|
|
203
|
+
peer,
|
|
204
|
+
deps.armTimer(BUSY_NOTICE_MS, () => {
|
|
205
|
+
busyTimers.delete(peer);
|
|
206
|
+
if (!hasWork(peer) || busyNotified.has(peer)) return;
|
|
207
|
+
busyNotified.add(peer);
|
|
208
|
+
void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer)));
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cancelAck(peer: string): void {
|
|
214
|
+
const cancel = acks.get(peer);
|
|
215
|
+
if (!cancel) return;
|
|
216
|
+
acks.delete(peer);
|
|
217
|
+
cancel();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Tell a queued peer, once, that their message is waiting. A turn that starts
|
|
222
|
+
* within {@link ACK_DELAY_MS} cancels this, because the typing indicator says
|
|
223
|
+
* the same thing without spending a bubble.
|
|
224
|
+
*/
|
|
225
|
+
function armAck(target: PresenceTarget): void {
|
|
226
|
+
// A turn for this peer is already running: its own progress notice covers
|
|
227
|
+
// this message, so a second acknowledgement would be noise.
|
|
228
|
+
if (isRunning(target.peer)) return;
|
|
229
|
+
if (acks.has(target.peer)) return;
|
|
230
|
+
const last = ackedAt.get(target.peer);
|
|
231
|
+
if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
|
|
232
|
+
acks.set(
|
|
233
|
+
target.peer,
|
|
234
|
+
deps.armTimer(ACK_DELAY_MS, () => {
|
|
235
|
+
acks.delete(target.peer);
|
|
236
|
+
if (!hasWork(target.peer) || isRunning(target.peer)) return;
|
|
237
|
+
ackedAt.set(target.peer, deps.now());
|
|
238
|
+
void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
|
|
239
|
+
// Still waiting after a minute behind another turn: say why.
|
|
240
|
+
armBusyNotice(target.peer, target.contextToken);
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
keepTyping,
|
|
247
|
+
typingOff,
|
|
248
|
+
pruneTyping() {
|
|
249
|
+
for (const peer of [...typing.keys()]) {
|
|
250
|
+
if (!hasWork(peer)) stopTyping(peer);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
armAck,
|
|
254
|
+
cancelAck,
|
|
255
|
+
armSlowNotice,
|
|
256
|
+
armBusyNotice,
|
|
257
|
+
stopSlowNotice,
|
|
258
|
+
stopBusyNotice,
|
|
259
|
+
stopTyping,
|
|
260
|
+
reset() {
|
|
261
|
+
for (const cancel of typing.values()) cancel();
|
|
262
|
+
typing.clear();
|
|
263
|
+
for (const cancel of acks.values()) cancel();
|
|
264
|
+
acks.clear();
|
|
265
|
+
for (const cancel of busyTimers.values()) cancel();
|
|
266
|
+
busyTimers.clear();
|
|
267
|
+
for (const cancel of slowTimers.values()) cancel();
|
|
268
|
+
slowTimers.clear();
|
|
269
|
+
typingCache.clear();
|
|
270
|
+
ackedAt.clear();
|
|
271
|
+
busyNotified.clear();
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Value-only: no module-level state.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { type PeerLanguage } from "./format.ts";
|
|
14
|
+
import { notice } from "./notices.ts";
|
|
14
15
|
|
|
15
16
|
/** The two command kinds. */
|
|
16
17
|
export type SlashKind = "snippet" | "action";
|
|
@@ -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";
|
|
@@ -25,8 +26,11 @@ import type { BotCredentials } from "./types.ts";
|
|
|
25
26
|
/** User-facing connection word; the internal `BridgeState` stays running/stopped. */
|
|
26
27
|
export type StatusConnection = "open" | "expired" | "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,17 +48,23 @@ 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. */
|
|
@@ -63,7 +73,11 @@ export function connectionOf(status: BridgeStatus | undefined): StatusConnection
|
|
|
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
|
|
|
@@ -140,8 +163,14 @@ function connectionRole(connection: StatusConnection): TuiRole {
|
|
|
140
163
|
}
|
|
141
164
|
}
|
|
142
165
|
|
|
166
|
+
/** The peer focus passed into the body: which row is selected and which slice shows. */
|
|
167
|
+
export interface PeerSelection {
|
|
168
|
+
selectedId?: string;
|
|
169
|
+
window?: { start: number; end: number };
|
|
170
|
+
}
|
|
171
|
+
|
|
143
172
|
/** Rows for the status screen body, each fitted to `width`. */
|
|
144
|
-
export function statusBody(view: StatusView, theme: Theme, width: number): string[] {
|
|
173
|
+
export function statusBody(view: StatusView, theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
145
174
|
const target = Math.max(1, width);
|
|
146
175
|
const lines = [
|
|
147
176
|
row(theme, target, "Account", view.account ?? "not logged in", view.account ? "label" : "meta"),
|
|
@@ -153,8 +182,13 @@ export function statusBody(view: StatusView, theme: Theme, width: number): strin
|
|
|
153
182
|
lines.push(row(theme, target, "Queued", String(view.queued), "meta"));
|
|
154
183
|
if (view.refused > 0) lines.push(row(theme, target, "Refused", refusedValue(view), "meta"));
|
|
155
184
|
if (view.mediaDropped > 0) lines.push(row(theme, target, "Media", `${view.mediaDropped} dropped`, "meta"));
|
|
156
|
-
if (view.
|
|
157
|
-
|
|
185
|
+
if (view.prompt) {
|
|
186
|
+
const age = formatDuration(Math.max(0, (view.now ?? Date.now()) - view.prompt.startedAt));
|
|
187
|
+
lines.push(row(theme, target, "Prompt", `${view.prompt.peer} · ${view.prompt.title} · ${age}`, "accent"));
|
|
188
|
+
} else if (view.promptPending) {
|
|
189
|
+
lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
|
|
190
|
+
}
|
|
191
|
+
lines.push(...peerLines(view.peerRows, theme, target, selection));
|
|
158
192
|
}
|
|
159
193
|
if (view.connection === "expired") {
|
|
160
194
|
lines.push("");
|
|
@@ -179,47 +213,98 @@ function peerValue(row: PeerRow): string {
|
|
|
179
213
|
return "idle";
|
|
180
214
|
}
|
|
181
215
|
|
|
182
|
-
/** One
|
|
183
|
-
function peerLines(rows: PeerRow[], theme: Theme, width: number): string[] {
|
|
216
|
+
/** One selectable line per peer, with a range row when the list is scrolled. */
|
|
217
|
+
function peerLines(rows: PeerRow[], theme: Theme, width: number, selection?: PeerSelection): string[] {
|
|
184
218
|
if (rows.length === 0) return [];
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
const start = selection?.window?.start ?? 0;
|
|
220
|
+
const end = selection?.window?.end ?? Math.min(rows.length, MAX_PEER_ROWS);
|
|
221
|
+
const shown = rows.slice(start, end);
|
|
222
|
+
const lines = ["", clip(` ${paint(theme, "meta", "Conversations")}`, width)];
|
|
223
|
+
for (const entry of shown) {
|
|
224
|
+
const selected = selection?.selectedId !== undefined && (entry.id ?? entry.label) === selection.selectedId;
|
|
189
225
|
const role: TuiRole = entry.status === "running" ? "accent" : entry.status === "queued" ? "warn" : "meta";
|
|
190
|
-
lines.push(
|
|
226
|
+
lines.push(
|
|
227
|
+
listRow(theme, width, {
|
|
228
|
+
indent: 2,
|
|
229
|
+
selected,
|
|
230
|
+
label: entry.label,
|
|
231
|
+
trailing: peerValue(entry),
|
|
232
|
+
trailingRole: role,
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
191
235
|
}
|
|
192
|
-
if (
|
|
236
|
+
if (selection?.window !== undefined) {
|
|
237
|
+
if (start > 0 || end < rows.length) {
|
|
238
|
+
lines.push(formatRange(theme, width, { start, end, total: rows.length }));
|
|
239
|
+
}
|
|
240
|
+
} else if (rows.length > shown.length) {
|
|
193
241
|
lines.push(clip(` ${paint(theme, "meta", `+${rows.length - shown.length} more`)}`, width));
|
|
194
242
|
}
|
|
195
243
|
return lines;
|
|
196
244
|
}
|
|
197
245
|
|
|
246
|
+
/** The peer actions the screen can run without closing. */
|
|
247
|
+
export interface StatusActions {
|
|
248
|
+
/** Stop the peer's running turn; returns the line to flash. */
|
|
249
|
+
stop(peer: string): string;
|
|
250
|
+
/** Start a fresh conversation for the peer; returns the line to flash. */
|
|
251
|
+
reset(peer: string): string;
|
|
252
|
+
/** Withdraw the waiting prompt; returns the line to flash. */
|
|
253
|
+
cancelPrompt(): string;
|
|
254
|
+
}
|
|
255
|
+
|
|
198
256
|
export interface StatusScreenOptions {
|
|
199
257
|
theme: Theme;
|
|
200
258
|
/** Live view, re-read on every render so `r` shows the current state. */
|
|
201
259
|
view: () => StatusView;
|
|
202
260
|
/** Called once when the screen closes, with the chosen action. */
|
|
203
261
|
onClose: (action: StatusAction) => void;
|
|
262
|
+
/** In-place peer actions; absent makes `s`/`d` explain that they are unavailable. */
|
|
263
|
+
actions?: StatusActions;
|
|
204
264
|
/** Repaint after a state change. */
|
|
205
265
|
requestRender: () => void;
|
|
206
266
|
}
|
|
207
267
|
|
|
208
268
|
/** The `/wechat` control screen: live connection facts plus every action. */
|
|
209
269
|
export class StatusScreenComponent implements Component {
|
|
270
|
+
private readonly cursor = new IdCursor();
|
|
271
|
+
private flash: string | undefined;
|
|
272
|
+
|
|
210
273
|
constructor(private readonly options: StatusScreenOptions) {}
|
|
211
274
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
275
|
+
/**
|
|
276
|
+
* The focused peer, or undefined when the list is empty. Not named `focused`:
|
|
277
|
+
* the host duck-types focusables with `"focused" in component` and writes a
|
|
278
|
+
* boolean there, which would shadow the method.
|
|
279
|
+
*/
|
|
280
|
+
private focusedPeer(view: StatusView): PeerRow | undefined {
|
|
281
|
+
const rows = view.peerRows;
|
|
282
|
+
if (rows.length === 0) return undefined;
|
|
283
|
+
return rows[this.cursor.indexOf(rows, (row) => row.id ?? row.label)];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private setFlash(message: string | undefined): void {
|
|
287
|
+
this.flash = message;
|
|
288
|
+
this.options.requestRender();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private hints(view: StatusView): ScreenHint[] {
|
|
292
|
+
const hints: ScreenHint[] = ["Esc close"];
|
|
293
|
+
if (view.peerRows.length > 0) {
|
|
294
|
+
hints.push("j/k move", { text: "s stop", color: "accent" }, { text: "d reset", color: "warning" }, "h history");
|
|
295
|
+
}
|
|
296
|
+
hints.push(
|
|
215
297
|
{ text: "l login", color: "accent" },
|
|
216
298
|
{ text: "n new", color: "accent" },
|
|
217
299
|
{ text: "o logout", color: "error" },
|
|
218
300
|
"r refresh",
|
|
219
|
-
|
|
301
|
+
);
|
|
302
|
+
return hints;
|
|
220
303
|
}
|
|
221
304
|
|
|
222
305
|
handleInput(data: string): void {
|
|
306
|
+
const view = this.options.view();
|
|
307
|
+
const peer = this.focusedPeer(view);
|
|
223
308
|
if (isCloseKey(data)) {
|
|
224
309
|
this.options.onClose("close");
|
|
225
310
|
return;
|
|
@@ -236,21 +321,97 @@ export class StatusScreenComponent implements Component {
|
|
|
236
321
|
this.options.onClose("logout");
|
|
237
322
|
return;
|
|
238
323
|
}
|
|
239
|
-
if (data === "r" || data === "R")
|
|
324
|
+
if (data === "r" || data === "R") {
|
|
325
|
+
this.setFlash(undefined);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (data === "a" || data === "A") {
|
|
329
|
+
this.options.onClose({ kind: "allow" });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (data === "c" || data === "C") {
|
|
333
|
+
if (!view.prompt) {
|
|
334
|
+
this.setFlash("No prompt waiting.");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.setFlash(this.options.actions?.cancelPrompt() ?? "Prompt cancel is unavailable.");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (data === "s" || data === "S" || data === "d" || data === "D" || data === "h" || data === "H") {
|
|
341
|
+
if (!peer) {
|
|
342
|
+
this.setFlash("No peer conversation yet.");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const id = peer.id ?? peer.label;
|
|
346
|
+
if (data === "h" || data === "H") {
|
|
347
|
+
this.options.onClose({ kind: "history", peer: id });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const actions = this.options.actions;
|
|
351
|
+
if (!actions) {
|
|
352
|
+
this.setFlash("Peer actions are unavailable here.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.setFlash(data === "s" || data === "S" ? actions.stop(id) : actions.reset(id));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const rows = view.peerRows;
|
|
359
|
+
const index = this.cursor.indexOf(rows, (row) => row.id ?? row.label);
|
|
360
|
+
switch (navIntent(data)) {
|
|
361
|
+
case "up":
|
|
362
|
+
this.select(index - 1, view);
|
|
363
|
+
return;
|
|
364
|
+
case "down":
|
|
365
|
+
this.select(index + 1, view);
|
|
366
|
+
return;
|
|
367
|
+
case "pageUp":
|
|
368
|
+
this.select(index - this.cursor.visible, view);
|
|
369
|
+
return;
|
|
370
|
+
case "pageDown":
|
|
371
|
+
this.select(index + this.cursor.visible, view);
|
|
372
|
+
return;
|
|
373
|
+
case "home":
|
|
374
|
+
this.select(0, view);
|
|
375
|
+
return;
|
|
376
|
+
case "end":
|
|
377
|
+
this.select(rows.length - 1, view);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
240
380
|
}
|
|
241
381
|
|
|
242
|
-
/**
|
|
382
|
+
/** Focus the row at `index` and repaint when it moves. */
|
|
383
|
+
private select(index: number, view: StatusView): void {
|
|
384
|
+
const rows = view.peerRows;
|
|
385
|
+
if (rows.length === 0) return;
|
|
386
|
+
this.cursor.select(rows, (row) => row.id ?? row.label, index);
|
|
387
|
+
this.options.requestRender();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The cursor is plain state and the view is live, so there is nothing to invalidate. */
|
|
243
391
|
invalidate(): void {}
|
|
244
392
|
|
|
245
393
|
render(width: number): string[] {
|
|
246
394
|
const target = Math.max(1, width);
|
|
247
395
|
const theme = this.options.theme;
|
|
248
|
-
|
|
396
|
+
const view = this.options.view();
|
|
397
|
+
const rows = view.peerRows;
|
|
398
|
+
this.cursor.visible = Math.min(MAX_PEER_ROWS, Math.max(1, rows.length));
|
|
399
|
+
this.cursor.clamp(rows, (row) => row.id ?? row.label);
|
|
400
|
+
const peer = this.focusedPeer(view);
|
|
401
|
+
if (rows.length > 0) this.cursor.ensureVisible(this.cursor.indexOf(rows, (row) => row.id ?? row.label));
|
|
402
|
+
const start = this.cursor.scroll;
|
|
403
|
+
const end = Math.min(rows.length, start + this.cursor.visible);
|
|
404
|
+
const selection: PeerSelection | undefined =
|
|
405
|
+
rows.length === 0
|
|
406
|
+
? undefined
|
|
407
|
+
: { selectedId: peer ? (peer.id ?? peer.label) : undefined, window: { start, end } };
|
|
408
|
+
const lines = [
|
|
249
409
|
titleRule(theme, target, `wechat${SEPARATORS.item}control`),
|
|
250
410
|
"",
|
|
251
|
-
...statusBody(
|
|
252
|
-
"",
|
|
253
|
-
screenHint(theme, target, this.hints()),
|
|
411
|
+
...statusBody(view, theme, target, selection),
|
|
254
412
|
];
|
|
413
|
+
if (this.flash) lines.push("", clip(` ${paint(theme, "meta", this.flash)}`, target));
|
|
414
|
+
lines.push("", screenHint(theme, target, this.hints(view)));
|
|
415
|
+
return lines;
|
|
255
416
|
}
|
|
256
417
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The timer
|
|
2
|
+
* The timer seams shared by the bridge, the remote-prompt channel, and login.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* so a pending timer never holds
|
|
4
|
+
* The bridge and the channel need a cancellable one-shot timer that a test can
|
|
5
|
+
* drive without waiting (the delayed acknowledgement, the typing keep-alive, the
|
|
6
|
+
* prompt timeout); login needs an abortable sleep between polls. The defaults use
|
|
7
|
+
* a real `setTimeout`, `unref`'d for the one-shot so a pending timer never holds
|
|
8
|
+
* the process open.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
/** Arm a one-shot timer; the returned function cancels it. */
|
|
@@ -16,3 +17,22 @@ export const defaultTimer: ArmTimer = (ms, fn) => {
|
|
|
16
17
|
(timer as { unref?: () => void }).unref?.();
|
|
17
18
|
return () => clearTimeout(timer);
|
|
18
19
|
};
|
|
20
|
+
|
|
21
|
+
/** An abortable sleep: resolves after `ms`, rejects when `signal` aborts. */
|
|
22
|
+
export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
if (signal?.aborted) {
|
|
25
|
+
reject(new Error("aborted"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
signal?.removeEventListener("abort", onAbort);
|
|
30
|
+
resolve();
|
|
31
|
+
}, ms);
|
|
32
|
+
const onAbort = () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(new Error("aborted"));
|
|
35
|
+
};
|
|
36
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
37
|
+
});
|
|
38
|
+
}
|
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.3",
|
|
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",
|