@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.
@@ -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 { notice, type PeerLanguage } from "./format.ts";
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: 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
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The timer seam shared by the bridge and the remote-prompt channel.
2
+ * The timer seams shared by the bridge, the remote-prompt channel, and login.
3
3
  *
4
- * Both need a cancellable one-shot timer that a test can drive without waiting:
5
- * the bridge for the delayed acknowledgement and the typing keep-alive, the
6
- * channel for the prompt timeout. The default is a real `setTimeout`, `unref`'d
7
- * so a pending timer never holds the process open.
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.1",
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",