@gavin-hu/my-pi-agent 0.11.2 → 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.
@@ -61,7 +61,9 @@ How the `send_wechat` call and its result look in the transcript — the call li
61
61
  can drive the agent; `allowedPeers` opts in others. An entry matches either id
62
62
  form (`alice` or `alice@im.wechat`), and a refused sender is named in
63
63
  `/wechat status` and in one terminal notice, so the id to allowlist can be
64
- copied from either.
64
+ copied from either. `a` on the control screen or `/wechat allow [peer]` writes
65
+ one entry into the project config; the running bridge picks it up on the next
66
+ `/reload` or restart, and the confirmation names the file.
65
67
  - **Session-bound** — the bridge runs while an account is stored and the Pi
66
68
  process is alive; it starts on `session_start`, and `/wechat logout` clears the
67
69
  account and stops it. The peer sessions outlive it on disk.
@@ -74,8 +76,11 @@ How the `send_wechat` call and its result look in the transcript — the call li
74
76
  | `/wechat login` | QR login; on scan, stores credentials, replaces a running poller, and starts the bridge. |
75
77
  | `/wechat new` | Start a fresh *host* session. Peer conversations are independent and survive it; they use an inbound `/new`. |
76
78
  | `/wechat status` | The same control screen as a bare `/wechat` (the one-line summary while a peer turn runs). |
79
+ | `/wechat doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
77
80
  | `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
78
81
  | `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
82
+ | `/wechat history <peer> [n]` | Print that peer's recent messages (default 10, max 20); read-only, and works without a live session. |
83
+ | `/wechat allow [peer]` | Add a refused sender to `wechat.allowedPeers` in the project `.pi/bytefly.json`, after a confirmation that names the file. Without an argument it offers the refused senders. |
79
84
  | `/wechat logout` | Confirm, then clear credentials, drop the bridge, and stop polling. |
80
85
  | `/wechat help` | List the subcommands with a line each. |
81
86
 
@@ -98,10 +103,15 @@ one-step recovery.
98
103
 
99
104
  In the TUI, `/wechat` opens a control screen with the account, connection
100
105
  state, owner, per-peer conversation rows (what each peer is doing, how long, and
101
- what is queued behind it), activity counters, and any pending remote prompt. Keys: `l` log in
102
- or reconnect, `n` start a new session, `o` log out, `r` refresh, and Esc closes.
103
- The subcommands above are shortcuts for the same actions and the only route
104
- outside the TUI; an unknown subcommand reports the valid ones.
106
+ what is queued behind it), activity counters, and the waiting remote prompt
107
+ (peer, question, and age).
108
+ `j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
109
+ peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
110
+ the screen and prints that peer's history; `a` closes it and offers the refused
111
+ senders to allow; `c` withdraws the waiting prompt. `l` logs in or reconnects,
112
+ `n` starts a new session, `o` logs out, `r` refreshes, and Esc closes. The
113
+ subcommands above are shortcuts for the same actions and the only route outside
114
+ the TUI; an unknown subcommand reports the valid ones.
105
115
 
106
116
  A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
107
117
  command typed at the terminal declines while one is running: `status` prints the
@@ -304,6 +314,9 @@ never touches the stored cursor.
304
314
 
305
315
  ## Troubleshooting
306
316
 
317
+ - **First, run `/wechat doctor`.** It prints the connection, token age, last
318
+ poll, poller lock, per-peer queue/session age, and the last failure, which
319
+ answers most "why is the bot quiet?" questions in one screen.
307
320
  - **`Another Pi session is already polling this WeChat account.`** — one runtime
308
321
  owns the account at a time, tracked by `<agent-dir>/wechat/poller.lock`.
309
322
  Quit the other `pi` process. A lock left by a process that already exited is
@@ -382,7 +395,7 @@ sessions are in-process).
382
395
 
383
396
  | Integration point | Detail |
384
397
  |---|---|
385
- | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `stop <peer>`, `reset <peer>`, `logout`, `help`; bare `/wechat` opens the control screen. |
398
+ | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
386
399
  | Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
387
400
  | Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
388
401
  | Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
@@ -483,7 +496,7 @@ status, which is what the old start/stop guidance used to do.
483
496
  | `login.ts` | QR login state machine. |
484
497
  | `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
485
498
  | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
486
- | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`) and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `paint`). |
499
+ | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
487
500
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
488
501
  | `credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
489
502
  | `state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
@@ -497,6 +510,7 @@ status, which is what the old start/stop guidance used to do.
497
510
  | `remote-ui.ts` | The peer session's UI context: dialogs and notices bound to that peer, the `askHuman` marker that delivers the file/text preface, and the `remotePrompts` switch. |
498
511
  | `slash.ts` | Inbound slash commands: the built-in registry (`/new`, `/stop`, `/status`, `/history`, `/help`), the session controls an action may invoke, and the pure parse/lookup/expand helpers. |
499
512
  | `history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
513
+ | `doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
500
514
  | `types.ts` | Wire and domain types. |
501
515
 
502
516
  ## Testing
@@ -25,7 +25,11 @@ import { messageOf } from "./errors.ts";
25
25
  import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./format.ts";
26
26
  import { notice } from "./notices.ts";
27
27
  import { createPresence } from "./presence.ts";
28
- import { createWechatInteractionChannel, type WechatInteractionChannel } from "./interaction.ts";
28
+ import {
29
+ createWechatInteractionChannel,
30
+ type PendingPromptInfo,
31
+ type WechatInteractionChannel,
32
+ } from "./interaction.ts";
29
33
  import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
30
34
  import { createPeerWorker, type PeerState, type PeerWorker } from "./peer-worker.ts";
31
35
  import type { PeerSession } from "./peer-session.ts";
@@ -95,6 +99,8 @@ export interface BridgeDeps {
95
99
  notify?: (message: string, kind?: "info" | "warning" | "error") => void;
96
100
  /** Called whenever the bridge's connection state changes, for a status chip. */
97
101
  onStateChange?: (state: BridgeState) => void;
102
+ /** Read the poller lock, for the doctor. */
103
+ loadLock?: () => { pid: number; at: number } | undefined;
98
104
  }
99
105
 
100
106
  export interface BridgeStatus {
@@ -114,6 +120,22 @@ export interface BridgeStatus {
114
120
  mediaDropped: number;
115
121
  /** Whether a remote dialog prompt is waiting for an answer. */
116
122
  promptPending: boolean;
123
+ /** The waiting prompt's peer, kind, title, and age. */
124
+ prompt?: PendingPromptInfo;
125
+ /** When this connection opened. */
126
+ startedAt?: number;
127
+ /** When the last poll settled, successful or not. */
128
+ lastPollAt?: number;
129
+ /** The last poll failure, cleared by a successful poll. */
130
+ lastPollError?: string;
131
+ /** The last outbound send failure, cleared by a successful send. */
132
+ lastSendError?: string;
133
+ /** The long-poll hold currently in use (server hint or configured). */
134
+ pollHoldMs?: number;
135
+ /** The stored account's login time. */
136
+ tokenCreatedAt?: number;
137
+ /** The poller lock on disk, for the doctor. */
138
+ lock?: { pid: number; at: number };
117
139
  }
118
140
 
119
141
  export interface SendResult {
@@ -144,6 +166,11 @@ export function createBridge(deps: BridgeDeps): Bridge {
144
166
  const armTimer = deps.armTimer ?? defaultTimer;
145
167
  let running = false;
146
168
  let expired = false;
169
+ let startedAt: number | undefined;
170
+ let lastPollAt: number | undefined;
171
+ let lastPollError: string | undefined;
172
+ let lastSendError: string | undefined;
173
+ let pollHoldMs: number | undefined;
147
174
  let abort: AbortController | undefined;
148
175
  let loopPromise: Promise<void> | undefined;
149
176
  let client: WechatClient | undefined;
@@ -204,6 +231,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
204
231
  notify,
205
232
  onExpired: handleExpired,
206
233
  forgetToken,
234
+ onSendError: (message) => {
235
+ lastSendError = message;
236
+ },
237
+ onSent: () => {
238
+ lastSendError = undefined;
239
+ },
207
240
  });
208
241
 
209
242
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
@@ -420,6 +453,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
420
453
  refreshLock: deps.refreshLock,
421
454
  isRunning: () => running,
422
455
  pollTimeout,
456
+ now: deps.now,
457
+ onPoll: (result) => {
458
+ lastPollAt = result.at;
459
+ lastPollError = result.error;
460
+ if (result.holdMs !== undefined) pollHoldMs = result.holdMs;
461
+ },
423
462
  });
424
463
 
425
464
  /**
@@ -438,6 +477,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
438
477
  budget.reset();
439
478
  admission.reset();
440
479
  inbound.reset();
480
+ lastPollAt = undefined;
481
+ lastPollError = undefined;
482
+ lastSendError = undefined;
483
+ pollHoldMs = undefined;
441
484
  }
442
485
 
443
486
  function releaseSafe(): void {
@@ -482,6 +525,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
482
525
  running = true;
483
526
  expired = false;
484
527
  resetEphemeral();
528
+ startedAt = deps.now();
485
529
  // One worker per connection: it owns the per-peer queues and sessions, so
486
530
  // a login (or a reconnect after an expiry) starts from a clean slate while
487
531
  // each peer's history stays on disk.
@@ -573,6 +617,14 @@ export function createBridge(deps: BridgeDeps): Bridge {
573
617
  refusedPeers: [...refusedPeers],
574
618
  mediaDropped: inbound.dropped(),
575
619
  promptPending: interaction.hasPending(),
620
+ prompt: interaction.pendingInfo(),
621
+ startedAt,
622
+ lastPollAt,
623
+ lastPollError,
624
+ lastSendError,
625
+ pollHoldMs: pollHoldMs ?? deps.config.pollHoldMs,
626
+ tokenCreatedAt: account?.createdAt,
627
+ lock: deps.loadLock?.(),
576
628
  };
577
629
  },
578
630
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `/wechat` command: `login`, `new`, `status`, `logout`, `help`.
2
+ * The `/wechat` command: `login`, `new`, `status`, `logout`, `allow`, `help`.
3
3
  *
4
4
  * The command is the operator's single entry point. `/wechat` (or `/wechat
5
5
  * status`) opens the control screen, where every action is a key; the verb
@@ -11,11 +11,20 @@
11
11
  * declines while a WeChat peer turn is running.
12
12
  */
13
13
 
14
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ CONFIG_DIR_NAME,
16
+ type ExtensionAPI,
17
+ type ExtensionCommandContext,
18
+ type ExtensionContext,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { join } from "node:path";
21
+ import { writeConfigValue } from "../../lib/config.ts";
22
+ import { sanitizeLine } from "../../lib/format.ts";
15
23
  import { supportsCustomUI } from "../../lib/interaction.ts";
16
- import type { Bridge } from "./bridge.ts";
24
+ import { isAllowedPeer, type Bridge } from "./bridge.ts";
17
25
  import type { WechatClient } from "./client.ts";
18
26
  import type { WechatConfig } from "./config.ts";
27
+ import { doctorReport } from "./doctor.ts";
19
28
  import { peerLabel, peerKey } from "./format.ts";
20
29
  import { waitForLogin, type LoginOutcome } from "./login.ts";
21
30
  import { defaultSleep } from "./timer.ts";
@@ -31,6 +40,8 @@ export interface CommandDeps {
31
40
  createLoginClient: (cwd: string) => WechatClient;
32
41
  saveAccount: (account: BotCredentials) => void;
33
42
  primaryAccount: () => BotCredentials | undefined;
43
+ /** Read a peer's recent messages for `/wechat history`; read-only. */
44
+ readHistory: (peer: string, count: number) => Promise<string>;
34
45
  logout: () => void;
35
46
  now: () => number;
36
47
  }
@@ -50,8 +61,11 @@ export const WECHAT_COMMANDS = [
50
61
  { verb: "login", description: "Log in by QR; on a scan, store the account and start the bridge" },
51
62
  { verb: "new", description: "Start a fresh host session (peer conversations are independent)" },
52
63
  { verb: "status", description: "Open the control screen (a one-line summary outside the TUI)" },
64
+ { verb: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
53
65
  { verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
54
66
  { verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
67
+ { verb: "history", description: "Print one peer's recent messages: /wechat history <peer> [n]" },
68
+ { verb: "allow", description: "Allow a refused sender in the project config: /wechat allow [peer]" },
55
69
  { verb: "logout", description: "Clear the stored account and stop the bridge" },
56
70
  { verb: "help", description: "List these commands" },
57
71
  ] as const satisfies readonly WechatCommand[];
@@ -98,6 +112,72 @@ function busyWithPeerTurn(ctx: ExtensionContext, deps: CommandDeps, verb: string
98
112
  return true;
99
113
  }
100
114
 
115
+ /** Print the read-only health report, in the TUI, over RPC, or in a script. */
116
+ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void> {
117
+ ctx.ui.notify(
118
+ doctorReport({
119
+ status: deps.getBridge()?.status(),
120
+ account: deps.primaryAccount(),
121
+ config: deps.loadConfig(ctx.cwd),
122
+ now: deps.now(),
123
+ }),
124
+ );
125
+ }
126
+
127
+ /** Add a refused sender to the project config after the operator confirms. */
128
+ async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
129
+ if (!ctx.hasUI) {
130
+ ctx.ui.notify("WeChat allow needs a confirmation dialog, which this mode has no UI for.", "warning");
131
+ return;
132
+ }
133
+ const candidate = peerArg?.trim() || (await chooseRefused(ctx, deps));
134
+ if (!candidate) return;
135
+ const config = deps.loadConfig(ctx.cwd);
136
+ if (isAllowedPeer(candidate, undefined, config.allowedPeers)) {
137
+ ctx.ui.notify(`${sanitizeLine(peerLabel(candidate))} is already allowed.`);
138
+ return;
139
+ }
140
+ const relative = join(CONFIG_DIR_NAME, "bytefly.json");
141
+ const label = sanitizeLine(peerLabel(candidate));
142
+ const confirmed = await ctx.ui.confirm(`Allow ${label}?`, `Add this sender to wechat.allowedPeers in ${relative}?`);
143
+ if (!confirmed) return;
144
+ try {
145
+ writeConfigValue(join(ctx.cwd, relative), "wechat", "allowedPeers", [...config.allowedPeers, candidate]);
146
+ ctx.ui.notify(`Saved ${label} to ${relative}. Run /reload or restart Pi to apply.`);
147
+ } catch (error) {
148
+ ctx.ui.notify(`Could not save the allowlist: ${messageOf(error)}`, "error");
149
+ }
150
+ }
151
+
152
+ /** Pick one of the refused senders, or explain that there is nothing to allow. */
153
+ async function chooseRefused(ctx: ExtensionContext, deps: CommandDeps): Promise<string | undefined> {
154
+ const refused = deps.getBridge()?.status().refusedPeers ?? [];
155
+ if (refused.length === 0) {
156
+ ctx.ui.notify("No refused sender to allow.", "warning");
157
+ return undefined;
158
+ }
159
+ return ctx.ui.select("Allow which sender?", refused);
160
+ }
161
+
162
+ /** Print one peer's recent messages; read-only, and works without a live session. */
163
+ async function runHistory(ctx: ExtensionCommandContext, deps: CommandDeps, args: string): Promise<void> {
164
+ const [peer = "", countArg] = args.trim().split(/\s+/).filter(Boolean);
165
+ const bridge = deps.getBridge();
166
+ if (!peer) {
167
+ const known = bridge?.status().peerStates.map((entry) => peerLabel(entry.peer)) ?? [];
168
+ ctx.ui.notify(`Usage: /wechat history <peer> [n]. Known: ${known.join(", ") || "none yet"}.`, "warning");
169
+ return;
170
+ }
171
+ const requested = Number.parseInt(countArg ?? "", 10);
172
+ const count = Math.min(20, Math.max(1, Number.isFinite(requested) ? requested : 10));
173
+ try {
174
+ const text = await deps.readHistory(peer, count);
175
+ ctx.ui.notify(text || `No history yet for ${peerLabel(peer)}.`);
176
+ } catch (error) {
177
+ ctx.ui.notify(`WeChat history failed: ${messageOf(error)}`, "warning");
178
+ }
179
+ }
180
+
101
181
  /**
102
182
  * React to a finished login. A confirmed scan stores the account and starts the
103
183
  * bridge immediately, so `/wechat login` is the only command needed to go
@@ -214,9 +294,37 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
214
294
  theme,
215
295
  view,
216
296
  onClose: done,
297
+ actions: {
298
+ stop: (peer) => {
299
+ const bridge = deps.getBridge();
300
+ if (!bridge) return "WeChat bridge is not running.";
301
+ return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
302
+ },
303
+ reset: (peer) => {
304
+ const bridge = deps.getBridge();
305
+ if (!bridge) return "WeChat bridge is not running.";
306
+ return bridge.reset(peer)
307
+ ? `Started a fresh conversation for ${peerLabel(peer)}.`
308
+ : `${peerLabel(peer)} is running; stop it before starting fresh.`;
309
+ },
310
+ cancelPrompt: () => {
311
+ const bridge = deps.getBridge();
312
+ if (!bridge?.status().promptPending) return "No prompt is waiting.";
313
+ bridge.channel().cancel();
314
+ return "Cancelled the waiting prompt.";
315
+ },
316
+ },
217
317
  requestRender: () => tui.requestRender(),
218
318
  }),
219
319
  );
320
+ if (typeof action === "object" && action?.kind === "history") {
321
+ await runHistory(ctx, deps, action.peer);
322
+ return;
323
+ }
324
+ if (typeof action === "object" && action?.kind === "allow") {
325
+ await runAllow(ctx, deps);
326
+ return;
327
+ }
220
328
  switch (action) {
221
329
  case "login":
222
330
  case "new":
@@ -296,6 +404,12 @@ async function dispatch(
296
404
  return runNew(ctx, deps);
297
405
  case "status":
298
406
  return runPanel(ctx, deps);
407
+ case "doctor":
408
+ return runDoctor(ctx, deps);
409
+ case "history":
410
+ return runHistory(ctx, deps, peerArg);
411
+ case "allow":
412
+ return runAllow(ctx, deps, peerArg || undefined);
299
413
  case "stop":
300
414
  return runPeerAction(ctx, deps, "stop", peerArg);
301
415
  case "reset":
@@ -311,7 +425,7 @@ async function dispatch(
311
425
  export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
312
426
  pi.registerCommand("wechat", {
313
427
  description:
314
- "WeChat bridge. /wechat opens the control screen (login | new | status | stop | reset | logout | help)",
428
+ "WeChat bridge. /wechat opens the control screen (login | new | status | doctor | stop | reset | history | allow | logout | help)",
315
429
  getArgumentCompletions: completeSubcommand,
316
430
  handler: async (args, ctx) => {
317
431
  const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -49,6 +49,10 @@ export interface DeliveryDeps {
49
49
  onExpired: () => void;
50
50
  /** Drop a peer's cached reply token. */
51
51
  forgetToken: (peer: string) => void;
52
+ /** The last send failure, for the doctor/status. */
53
+ onSendError?: (message: string) => void;
54
+ /** A message got through; clear the last failure. */
55
+ onSent?: () => void;
52
56
  }
53
57
 
54
58
  /** The bridge's outbound surface. */
@@ -137,9 +141,13 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
137
141
  if (failureNotified.has(target.peer)) return;
138
142
  failureNotified.add(target.peer);
139
143
  const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
144
+ deps.onSendError?.(messageOf(error));
140
145
  deps.notify(`WeChat ${label} failed${partial}: ${messageOf(error)}`, "warning");
141
146
  });
142
- if (!failed && delivery.ok) failureNotified.delete(target.peer);
147
+ if (!failed && delivery.ok) {
148
+ failureNotified.delete(target.peer);
149
+ deps.onSent?.();
150
+ }
143
151
  return delivery.ok;
144
152
  }
145
153
 
@@ -169,7 +177,11 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
169
177
  });
170
178
  // `sendLocalFile` reports only success, and the caller may fall back to text;
171
179
  // without this line the operator would never know the attachment was dropped.
172
- if (!sent && !signal?.aborted) deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
180
+ if (!sent && !signal?.aborted) {
181
+ deps.onSendError?.(`file delivery failed: ${fileName}`);
182
+ deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
183
+ }
184
+ if (sent) deps.onSent?.();
173
185
  return sent;
174
186
  },
175
187
 
@@ -178,11 +190,13 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
178
190
  const delivery = await deliverText(owner, contextToken, text, signal, (error, progress) => {
179
191
  deps.forgetToken(owner);
180
192
  const partial = progress.total > 1 ? ` after ${progress.sent} of ${progress.total} parts were sent` : "";
193
+ deps.onSendError?.(messageOf(error));
181
194
  failure = `${messageOf(error)}${partial}`;
182
195
  });
183
196
  if (delivery.expired) return { ok: false, error: "WeChat session expired." };
184
197
  if (delivery.empty) return { ok: false, error: "Nothing to send after formatting." };
185
198
  if (!delivery.ok) return { ok: false, error: failure ?? "WeChat send failed." };
199
+ deps.onSent?.();
186
200
  return { ok: true };
187
201
  },
188
202
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The plain-text health report behind `/wechat doctor`.
3
+ *
4
+ * It answers the operator's first question — why is the bot quiet or slow? — from
5
+ * facts the bridge already has: the connection, the token's age, the last poll,
6
+ * the poller lock, per-peer sessions, and the last send/poll failure. Pure, so
7
+ * the wording and the age math are unit-tested without a bridge.
8
+ */
9
+
10
+ import { formatDuration } from "../../lib/format.ts";
11
+ import type { BridgeStatus } from "./bridge.ts";
12
+ import type { WechatConfig } from "./config.ts";
13
+ import type { BotCredentials } from "./types.ts";
14
+ import { peerLabel } from "./format.ts";
15
+
16
+ export interface DoctorInput {
17
+ /** Undefined when the bridge has never been built in this runtime. */
18
+ status?: BridgeStatus;
19
+ account?: BotCredentials;
20
+ config: WechatConfig;
21
+ now: number;
22
+ }
23
+
24
+ const LABEL = 11;
25
+
26
+ /** One `Label value` line. */
27
+ function row(label: string, value: string): string {
28
+ return `${label.padEnd(LABEL)}${value}`;
29
+ }
30
+
31
+ /** `3.2s` since `at`, or a placeholder. */
32
+ function age(now: number, at: number | undefined): string {
33
+ return at === undefined ? "unknown" : `${formatDuration(Math.max(0, now - at))} ago`;
34
+ }
35
+
36
+ function connection(status: BridgeStatus | undefined, account: BotCredentials | undefined, now: number): string {
37
+ if (!status?.open) {
38
+ return status?.expired ? "expired · run /wechat login" : "closed";
39
+ }
40
+ const owner = status.owner ? peerLabel(status.owner) : "unclaimed";
41
+ const token = account ? `${formatDuration(Math.max(0, now - account.createdAt))} old` : "unknown age";
42
+ return `open · owner ${owner} · token ${token}`;
43
+ }
44
+
45
+ function poll(status: BridgeStatus | undefined, now: number): string {
46
+ if (!status) return "not started";
47
+ const last =
48
+ status.lastPollAt === undefined
49
+ ? "no poll yet"
50
+ : `last ${formatDuration(Math.max(0, now - status.lastPollAt))} ago`;
51
+ const hold = status.pollHoldMs === undefined ? "server hold" : `hold ${formatDuration(status.pollHoldMs)}`;
52
+ const error = status.lastPollError ? ` · error: ${status.lastPollError}` : "";
53
+ return `${last} · ${hold}${error}`;
54
+ }
55
+
56
+ function lockLine(status: BridgeStatus | undefined, now: number): string {
57
+ const lock = status?.lock;
58
+ if (!lock) return status?.open ? "not held" : "not running";
59
+ return `pid ${lock.pid} · ${age(now, lock.at)}`;
60
+ }
61
+
62
+ function peers(status: BridgeStatus | undefined): string[] {
63
+ if (!status || status.peerStates.length === 0) return [row("Peers", "none")];
64
+ const running = status.peerStates.filter((entry) => entry.state.status === "running").length;
65
+ const lines = [row("Peers", `${status.peerStates.length} · ${running} running · ${status.queued} queued`)];
66
+ for (const entry of status.peerStates) {
67
+ const state = entry.state;
68
+ const what =
69
+ state.status === "running"
70
+ ? `running ${formatDuration(state.runningMs ?? 0)}`
71
+ : state.status === "queued"
72
+ ? `queued ${state.queued}`
73
+ : "idle";
74
+ const session = state.sessionAgeMs === undefined ? "" : ` · session ${formatDuration(state.sessionAgeMs)}`;
75
+ lines.push(row(` ${peerLabel(entry.peer)}`, `${what}${session}`));
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ function recent(status: BridgeStatus | undefined): string {
81
+ const errors = [
82
+ status?.lastSendError ? `send: ${status.lastSendError}` : undefined,
83
+ status?.lastPollError ? `poll: ${status.lastPollError}` : undefined,
84
+ ].filter((entry): entry is string => entry !== undefined);
85
+ return row("Recent", errors.length > 0 ? errors.join(" · ") : "no recent failure");
86
+ }
87
+
88
+ function media(status: BridgeStatus | undefined): string {
89
+ const dropped = status?.mediaDropped ?? 0;
90
+ const refused = status?.refused ?? 0;
91
+ const names = status?.refusedPeers.join(", ");
92
+ return row(
93
+ "Media",
94
+ `${dropped} dropped · ${refused} refused${names ? ` (${names})` : ""} · ${status?.promptPending ? "prompt waiting" : "no prompt"}`,
95
+ );
96
+ }
97
+
98
+ function configLine(config: WechatConfig): string {
99
+ const overrides = Object.keys(config.peers).length;
100
+ const limits = config.maxTurnsPerHour > 0 ? `${config.maxTurnsPerHour} turns/h` : "no turn cap";
101
+ return row(
102
+ "Config",
103
+ `typing ${config.typingIndicator ? "on" : "off"} · prompts ${config.remotePrompts ? "on" : "off"} · ` +
104
+ `${config.allowedPeers.length} allowed · ${overrides} peer override(s) · ${limits}`,
105
+ );
106
+ }
107
+
108
+ /** The doctor report, one line per fact. */
109
+ export function doctorReport(input: DoctorInput): string {
110
+ const { status, account, config, now } = input;
111
+ return [
112
+ "wechat — health",
113
+ row("Connection", connection(status, account, now)),
114
+ row("Poll", poll(status, now)),
115
+ row("Lock", lockLine(status, now)),
116
+ ...peers(status),
117
+ recent(status),
118
+ media(status),
119
+ configLine(config),
120
+ ].join("\n");
121
+ }
@@ -23,7 +23,7 @@ import { registerCommands, type CommandDeps } from "./commands.ts";
23
23
  import { messageOf } from "./errors.ts";
24
24
  import { loadWechatConfig } from "./config.ts";
25
25
  import { clearCredentials, primaryAccount, saveAccount, wechatDir } from "./credentials.ts";
26
- import { acquireLock, lockPath, refreshLock, releaseLock } from "./lock.ts";
26
+ import { acquireLock, lockPath, readLock, refreshLock, releaseLock } from "./lock.ts";
27
27
  import { openPeerSession, type PeerSession, type PeerSessionOptions } from "./peer-session.ts";
28
28
  import { createPeerUIContext } from "./remote-ui.ts";
29
29
  import { peerKey } from "./format.ts";
@@ -127,6 +127,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
127
127
  acquireLock: () => acquireLock(lockPath(), lockDeps()),
128
128
  refreshLock: () => refreshLock(lockPath(), lockDeps()),
129
129
  releaseLock: () => releaseLock(lockPath(), process.pid),
130
+ loadLock: () => readLock(lockPath()),
130
131
  notify: (message, kind) => {
131
132
  ctxRef?.ui.notify(message, kind);
132
133
  },
@@ -180,6 +181,8 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
180
181
  },
181
182
  saveAccount,
182
183
  primaryAccount,
184
+ readHistory: (peer, count) =>
185
+ readPeerHistory(ctxRef?.cwd ?? process.cwd(), join(wechatDir(), "sessions"), peer, count),
183
186
  logout: () => {
184
187
  clearCredentials();
185
188
  // The account is the on/off switch: without one there is nothing to report,
@@ -38,6 +38,8 @@ export interface WechatInteractionDeps {
38
38
  language: () => PeerLanguage;
39
39
  /** Arm a cancellable one-shot timer. Defaults to a real, unref'd `setTimeout`. */
40
40
  armTimer?: ArmTimer;
41
+ /** Clock for the prompt's start time; defaults to `Date.now`. */
42
+ now?: () => number;
41
43
  }
42
44
 
43
45
  export interface WechatInteractionChannel extends InteractionChannel {
@@ -45,13 +47,27 @@ export interface WechatInteractionChannel extends InteractionChannel {
45
47
  handleInbound(peer: string, text: string): boolean;
46
48
  /** Whether a prompt is currently waiting for a reply. */
47
49
  hasPending(): boolean;
50
+ /** The waiting prompt, for the control screen; undefined when none waits. */
51
+ pendingInfo(): PendingPromptInfo | undefined;
52
+ /** Withdraw the waiting prompt: tell the peer and resolve it cancelled. */
53
+ cancel(): void;
48
54
  /** Drop any pending prompt (turn settled or the bridge stopped). */
49
55
  reset(): void;
50
56
  }
51
57
 
58
+ /** The waiting prompt, as the control screen reads it. */
59
+ export interface PendingPromptInfo {
60
+ peer: string;
61
+ kind: InteractionRequest["kind"];
62
+ title: string;
63
+ /** When the prompt was registered, in epoch milliseconds. */
64
+ startedAt: number;
65
+ }
66
+
52
67
  interface Pending {
53
68
  request: InteractionRequest;
54
69
  peer: string;
70
+ startedAt: number;
55
71
  resolve: (answer: InteractionAnswer) => void;
56
72
  signal?: AbortSignal;
57
73
  onAbort: () => void;
@@ -61,6 +77,7 @@ interface Pending {
61
77
 
62
78
  export function createWechatInteractionChannel(deps: WechatInteractionDeps): WechatInteractionChannel {
63
79
  const arm = deps.armTimer ?? defaultTimer;
80
+ const now = deps.now ?? Date.now;
64
81
  let pending: Pending | undefined;
65
82
  /**
66
83
  * Free text captured from a `select` "Other" reply, per peer: the immediately
@@ -88,6 +105,26 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
88
105
  return pending !== undefined;
89
106
  },
90
107
 
108
+ pendingInfo(): PendingPromptInfo | undefined {
109
+ const current = pending;
110
+ return current === undefined
111
+ ? undefined
112
+ : {
113
+ peer: current.peer,
114
+ kind: current.request.kind,
115
+ title: current.request.title,
116
+ startedAt: current.startedAt,
117
+ };
118
+ },
119
+
120
+ cancel(): void {
121
+ if (!pending) return;
122
+ // The peer saw the prompt; one line says it was withdrawn. `reset` is the
123
+ // silent cleanup path and deliberately does not send this.
124
+ void deps.send(notice("timeout", deps.language()));
125
+ settle({ kind: "cancelled" });
126
+ },
127
+
91
128
  async post(text: string, signal?: AbortSignal): Promise<void> {
92
129
  await deps.send(text, signal);
93
130
  },
@@ -117,7 +154,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
117
154
  const cancelTimers: Array<() => void> = [];
118
155
  const answer = new Promise<InteractionAnswer>((resolve) => {
119
156
  const onAbort = () => settle({ kind: "cancelled" });
120
- pending = { request, peer, resolve, signal, onAbort, cancelTimers };
157
+ pending = { request, peer, startedAt: now(), resolve, signal, onAbort, cancelTimers };
121
158
  signal?.addEventListener("abort", onAbort, { once: true });
122
159
  // An ignored question must not freeze the session: nudge once, then
123
160
  // cancel so the model gets a clean answer instead of a hang.
@@ -48,10 +48,19 @@ function parseLock(text: string): LockInfo | undefined {
48
48
  }
49
49
  }
50
50
 
51
+ /** The raw lock record, or undefined when the file is missing or corrupt. */
52
+ export function readLock(path: string): LockInfo | undefined {
53
+ try {
54
+ if (!existsSync(path)) return undefined;
55
+ return parseLock(readFileSync(path, "utf-8"));
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
51
61
  /** True when the existing lock blocks a new owner. */
52
62
  export function isLocked(path: string, deps: LockDeps): boolean {
53
- if (!existsSync(path)) return false;
54
- const info = parseLock(readFileSync(path, "utf-8"));
63
+ const info = readLock(path);
55
64
  if (!info) return false;
56
65
  if (info.pid === deps.pid) return false;
57
66
  const stale = deps.now() - info.at > (deps.staleMs ?? DEFAULT_STALE_MS);
@@ -75,6 +75,8 @@ export interface PeerState {
75
75
  queued: number;
76
76
  /** How long the running turn has been going, in milliseconds. */
77
77
  runningMs?: number;
78
+ /** How long the peer's session has been open, when one is open. */
79
+ sessionAgeMs?: number;
78
80
  }
79
81
 
80
82
  export interface PeerSnapshot {
@@ -110,6 +112,8 @@ interface PeerEntry {
110
112
  /** Monotonic start time of the running turn, for `runningMs`. */
111
113
  startedAt?: number;
112
114
  session?: PeerSession;
115
+ /** When the current session opened, for the doctor's session age. */
116
+ sessionSince?: number;
113
117
  opening?: Promise<PeerSession>;
114
118
  disposeTimer?: () => void;
115
119
  }
@@ -164,15 +168,18 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
164
168
  function stateOf(peer: string): PeerState {
165
169
  const target = peers.get(peer);
166
170
  if (!target) return { status: "idle", queued: 0 };
171
+ const sessionAge =
172
+ target.sessionSince === undefined ? undefined : { sessionAgeMs: Math.max(0, now() - target.sessionSince) };
167
173
  if (target.running) {
168
174
  return {
169
175
  status: "running",
170
176
  queued: target.queue.length + target.merged,
171
177
  runningMs: target.startedAt === undefined ? 0 : now() - target.startedAt,
178
+ ...sessionAge,
172
179
  };
173
180
  }
174
181
  const queued = target.queue.length + target.merged;
175
- return { status: queued > 0 ? "queued" : "idle", queued };
182
+ return { status: queued > 0 ? "queued" : "idle", queued, ...sessionAge };
176
183
  }
177
184
 
178
185
  /** Drop the peer's session and queue. */
@@ -182,6 +189,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
182
189
  cancelIdleSweep(target);
183
190
  target.session?.dispose();
184
191
  target.session = undefined;
192
+ target.sessionSince = undefined;
185
193
  target.opening = undefined;
186
194
  }
187
195
 
@@ -212,6 +220,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
212
220
  throw new Error("The WeChat bridge closed while opening the session.");
213
221
  }
214
222
  target.session = opened;
223
+ target.sessionSince = now();
215
224
  return opened;
216
225
  })
217
226
  .finally(() => {
@@ -45,6 +45,9 @@ export interface PollDeps {
45
45
  onMessages: (messages: WeixinMessage[]) => void;
46
46
  refreshLock: () => void;
47
47
  isRunning: () => boolean;
48
+ now: () => number;
49
+ /** Report the last poll attempt, for the doctor/status. */
50
+ onPoll?: (result: { at: number; error?: string; holdMs?: number }) => void;
48
51
  pollTimeout: (serverHintMs: number | undefined) => number;
49
52
  }
50
53
 
@@ -82,6 +85,10 @@ export function createPollLoop(deps: PollDeps): PollLoop {
82
85
  } catch (error) {
83
86
  if (signal.aborted || !deps.isRunning()) break;
84
87
  if (watchdog.signal.aborted) {
88
+ deps.onPoll?.({
89
+ at: deps.now(),
90
+ error: `timed out after ${Math.round(watchdogMs / 1000)}s`,
91
+ });
85
92
  // A wedged request, not a server answer: retry at once with the same
86
93
  // cursor, so nothing is skipped, and say so once.
87
94
  if (!timeoutNotified) {
@@ -90,6 +97,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
90
97
  }
91
98
  continue;
92
99
  }
100
+ deps.onPoll?.({ at: deps.now(), error: messageOf(error) });
93
101
  deps.notify(`WeChat poll failed: ${messageOf(error)}`, "warning");
94
102
  try {
95
103
  await deps.sleep(BACKOFF_MS, signal);
@@ -112,6 +120,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
112
120
  if (!rejectedNotified) {
113
121
  rejectedNotified = true;
114
122
  const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
123
+ deps.onPoll?.({ at: deps.now(), error: `rejected ret ${code}` });
115
124
  deps.notify(`WeChat poll rejected (ret ${code}); retrying.`, "warning");
116
125
  }
117
126
  try {
@@ -130,6 +139,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
130
139
  }
131
140
  deps.onMessages(response.msgs);
132
141
  deps.refreshLock();
142
+ deps.onPoll?.({ at: deps.now(), holdMs: serverHint ?? deps.holdMs() });
133
143
  }
134
144
  },
135
145
  };
@@ -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: every operator action
7
- * (login, new session, logout) is a key here, so the user never has to remember
8
- * a subcommand. It shows the live connection state and closes with an action so
9
- * the command can run it after the screen is gone and input focus is free.
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
- /** What the status screen closed with: an action to dispatch, or `close`. */
29
- export type StatusAction = "close" | "login" | "new" | "logout";
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 summarising the rest. */
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(account: BotCredentials | undefined, status: BridgeStatus | undefined): 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.promptPending) lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
157
- lines.push(...peerLines(view.peerRows, theme, target));
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 indented line per peer, capped, so the screen stays compact. */
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 shown = rows.slice(0, MAX_PEER_ROWS);
186
- const lines = [""];
187
- for (const [index, entry] of shown.entries()) {
188
- const value = peerValue(entry);
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(row(theme, width, index === 0 ? "Conversations" : "", value, role));
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 (rows.length > shown.length) {
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
- private hints(): ScreenHint[] {
213
- return [
214
- "Esc close",
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") this.options.requestRender();
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
- /** Stateless: every value is read live from `view()` on render. */
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
- return [
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(this.options.view(), theme, target),
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.11.2",
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",