@gavin-hu/my-pi-agent 0.11.2 → 0.11.4

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