@gavin-hu/my-pi-agent 0.16.0 → 0.17.0

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.
Files changed (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
- import { errorResult } from "../../lib/tool-result.ts";
12
+ import { errorResult, textResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
14
  import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
15
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
@@ -82,62 +82,45 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
82
82
  ctx as ExtensionContext,
83
83
  );
84
84
  const flags = `${call.caseSensitive ? "" : "i"}${watch.wake ? ", wake" : ""}`;
85
- return {
86
- content: [
87
- {
88
- type: "text" as const,
89
- text: `Watching ${watch.id}: ${sourceLabel(watch)} → /${sanitizeLine(watch.pattern)}/${flags}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
90
- },
91
- ],
92
- details: { action: "add", watch } satisfies WatchDetails,
93
- };
85
+ return textResult(
86
+ `Watching ${watch.id}: ${sourceLabel(watch)} → /${sanitizeLine(watch.pattern)}/${flags}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
87
+ { action: "add", watch } satisfies WatchDetails,
88
+ );
94
89
  }
95
90
 
96
91
  case "list": {
97
92
  const watches = runtime.list();
98
- return {
99
- content: [{ type: "text" as const, text: formatWatchList(watches) }],
100
- details: { action: "list", watches } satisfies WatchDetails,
101
- };
93
+ return textResult(formatWatchList(watches), { action: "list", watches } satisfies WatchDetails);
102
94
  }
103
95
 
104
96
  case "status": {
105
97
  const watch = runtime.get(call.id);
106
98
  if (!watch) return errorResult("status", `no watcher "${call.id}".`);
107
- return {
108
- content: [{ type: "text" as const, text: formatWatchStatus(watch) }],
109
- details: { action: "status", watch } satisfies WatchDetails,
110
- };
99
+ return textResult(formatWatchStatus(watch), { action: "status", watch } satisfies WatchDetails);
111
100
  }
112
101
 
113
102
  case "matches": {
114
103
  const result = runtime.matches(call.id, call.lines);
115
104
  if (!result) return errorResult("matches", `no watcher "${call.id}".`);
116
105
  const text = formatTail("matches", result.watch, result.lines, 4000);
117
- return {
118
- content: [{ type: "text" as const, text: text.text }],
119
- details: {
120
- action: "matches",
121
- watch: result.watch,
122
- matches: result.lines.join("\n"),
123
- truncated: text.truncated,
124
- } satisfies WatchDetails,
125
- };
106
+ return textResult(text.text, {
107
+ action: "matches",
108
+ watch: result.watch,
109
+ matches: result.lines.join("\n"),
110
+ truncated: text.truncated,
111
+ } satisfies WatchDetails);
126
112
  }
127
113
 
128
114
  case "logs": {
129
115
  const result = runtime.logs(call.id, call.lines);
130
116
  if (!result) return errorResult("logs", `no watcher "${call.id}".`);
131
117
  const text = formatTail("output", result.watch, result.lines, 4000);
132
- return {
133
- content: [{ type: "text" as const, text: text.text }],
134
- details: {
135
- action: "logs",
136
- watch: result.watch,
137
- logs: result.lines.join("\n"),
138
- truncated: text.truncated,
139
- } satisfies WatchDetails,
140
- };
118
+ return textResult(text.text, {
119
+ action: "logs",
120
+ watch: result.watch,
121
+ logs: result.lines.join("\n"),
122
+ truncated: text.truncated,
123
+ } satisfies WatchDetails);
141
124
  }
142
125
 
143
126
  case "stop": {
@@ -145,34 +128,23 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
145
128
  if (!before) return errorResult("stop", `no watcher "${call.id}".`);
146
129
  const stopped = runtime.stop(call.id);
147
130
  const watch = runtime.get(call.id) ?? before;
148
- return {
149
- content: [
150
- {
151
- type: "text" as const,
152
- text: stopped
153
- ? `Stopped ${watch.id} (${sourceLabel(watch)}).`
154
- : `${watch.id} was not running; nothing to stop.`,
155
- },
156
- ],
157
- details: { action: "stop", watch, stopped } satisfies WatchDetails,
158
- };
131
+ return textResult(
132
+ stopped
133
+ ? `Stopped ${watch.id} (${sourceLabel(watch)}).`
134
+ : `${watch.id} was not running; nothing to stop.`,
135
+ { action: "stop", watch, stopped } satisfies WatchDetails,
136
+ );
159
137
  }
160
138
 
161
139
  case "clear": {
162
140
  const result = runtime.clear(call.id, call.all ?? false);
163
141
  if (result.refused) return errorResult("clear", result.refused);
164
- return {
165
- content: [
166
- {
167
- type: "text" as const,
168
- text:
169
- result.cleared === 0
170
- ? "Nothing to clear."
171
- : `Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`,
172
- },
173
- ],
174
- details: { action: "clear", cleared: result.cleared, watches: runtime.list() } satisfies WatchDetails,
175
- };
142
+ return textResult(
143
+ result.cleared === 0
144
+ ? "Nothing to clear."
145
+ : `Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`,
146
+ { action: "clear", cleared: result.cleared, watches: runtime.list() } satisfies WatchDetails,
147
+ );
176
148
  }
177
149
  }
178
150
  } catch (error) {
@@ -47,8 +47,9 @@ How the `send_wechat` call and its result look in the transcript — the call li
47
47
  - **Peer feedback** — a burst that arrives while a turn is still running is
48
48
  answered once, a message waiting on another conversation is acknowledged once,
49
49
  a slow turn says so, and a turn that ends without text says so instead of going
50
- quiet. Notices and `/help` follow the peer's language. The peer can also stop
51
- (`/stop`), restart (`/new`), and ask what is happening (`/status`).
50
+ quiet. Notices and `/help` follow the peer's language: a Chinese message sets it,
51
+ and a language-neutral reply (`1`, `ok`) does not switch it back. The peer can
52
+ also stop (`/stop`), restart (`/new`), and ask what is happening (`/status`).
52
53
  - **Remote prompts** — the peer session's own UI is bound to that peer, so
53
54
  `ask_user_question`, plan approval, and any other dialog raised inside a
54
55
  WeChat turn is sent to WeChat and answered there. Terminal turns keep the TUI,
@@ -174,14 +175,14 @@ needs. Per action:
174
175
  |---|---|
175
176
  | Sends a message | The native typing indicator starts as soon as the message is picked up; the answer is sent when the run settles. |
176
177
  | Sends several messages quickly | At most one acknowledgement, then a single answer that covers the burst in order. |
177
- | Waits behind a long turn | The indicator keeps refreshing, and after four seconds one short `收到,正在处理…` / “Got it — working on it.”; a turn still running at 30 seconds adds one `还在处理这条…` / “Still working on this one”. |
178
+ | Waits behind a long turn | The indicator keeps refreshing, and after four seconds one short `收到,正在排队,轮到就回你。` / “Got it — queued; I’ll answer when it’s my turn.”; a turn still running at 30 seconds adds one `还在处理这条,发送 /stop 可取消。` / “Still working — send /stop to cancel.”, so the way out is named when it is wanted. |
178
179
  | Sends while another peer's turn runs | One line after four seconds, then one more at a minute (`前面还有一条正在处理…` / “Your message is queued behind another conversation…”), and the answer when the slot frees. |
179
180
  | Sends a photo or a file | The model reads it; an attachment that could not be read (too large, a failed download, no key) is named in the answer. |
180
181
  | Sends a voice note or video | One line: voice without a transcript and video are not supported. |
181
182
  | Reads a long answer | Streamed as it is produced — one part per paragraph, per two silent seconds, or per 400 characters — and numbered (`[1/3] …`) only when a single part exceeds `maxReplyChars`. |
182
183
  | Reads an answer | Plain chat text — Markdown fences, headings, and emphasis markers are flattened, code-block bodies survive. |
183
- | Is asked a question | A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at 90 seconds and cancelled at three minutes. |
184
- | Types `/help`, `/new`, `/stop`, or `/status` | The command list, a fresh conversation, a stopped answer, or the current state; `/new` is declined while their run is in flight. |
184
+ | Is asked a question | A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at the halfway point of its window and then cancelled: three minutes for a dialog, ten for a plan review, where the plan has to be read first. |
185
+ | Types `/help`, `/new`, `/stop`, or `/status` | The command list, a fresh conversation, a stopped answer, or the current state — including the dialog a blocked turn is waiting on; `/new` is declined while their run is in flight. A command works while a dialog waits: it is never read as an answer to it. |
185
186
  | Types `/history` | The last eight messages of this conversation (or the requested number, up to 20), one line each. |
186
187
  | Floods the chat | Past eight queued messages, one line asking to wait rather than an unbounded queue. |
187
188
  | Sends the same message twice (a server replay) | Answered once: the WeChat message id is remembered for an hour, across a reconnect or reload. |
@@ -191,9 +192,10 @@ needs. Per action:
191
192
  | Gets a turn that could not start | `处理失败,请稍后重试。` / “Something went wrong — please try again.”, and the terminal says why. |
192
193
  | Keeps messaging an expired or logged-out bridge | Nothing — the bot goes quiet, and only the terminal chip says why. |
193
194
 
194
- Notices are one line and in the peer's language (Chinese when their message
195
- contains Chinese). The bridge never sends greetings, read receipts, or progress
196
- chatter: WeChat notifies per message, so every bubble has to carry information.
195
+ Notices are one line and in the peer's language (Chinese once their messages
196
+ contain Chinese; a bare `1` or `ok` does not switch it back). The bridge never
197
+ sends greetings, read receipts, or progress chatter: WeChat notifies per message,
198
+ so every bubble has to carry information.
197
199
 
198
200
  ### The reply contract
199
201
 
@@ -204,7 +206,7 @@ inside the answer it is already waiting for — not with a second “working on
204
206
  | Situation | What the peer gets |
205
207
  |---|---|
206
208
  | 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
207
- | 1st message, peer busy | typing, `收到,正在处理…` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
209
+ | 1st message, peer busy | typing, `收到,正在排队,轮到就回你。` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
208
210
  | 2nd message after the acknowledgement, still queued | nothing new; it is merged into the same turn and the one answer covers both in order |
209
211
  | 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
210
212
  | 3rd…Nth in a burst | same as the queued case; past eight queued, one line asking to wait |
@@ -217,17 +219,33 @@ starts, and a reply is delivered when the run settles, not token by token.
217
219
 
218
220
  A dialog raised while a WeChat turn is in flight is forwarded to the sender:
219
221
 
220
- - `select` — a numbered list; reply with a number, the option text, or any other
221
- text for the free-form “Other” entry.
222
+ - `select` — a numbered list; reply with a number or the option text. Free text
223
+ becomes the “Other” answer only when the list carries that entry; the hint says
224
+ so, so a plan review without it does not pretend free text is a choice.
222
225
  - `confirm` — reply 是/否, 确定/不是, or yes/no.
223
226
  - `input` / `editor` — reply with the text.
224
- - Reply 取消 (or cancel) to dismiss.
227
+ - Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
228
+ `exit plan mode` are recognized as cancel intent. In a plan review a dismissal
229
+ leaves plan mode without approving.
225
230
  - Hints are bilingual (Chinese and English). A reply that cannot be read
226
231
  re-sends the prompt with a short “didn’t recognize that” notice instead of
227
232
  repeating it silently.
233
+ - A command is not an answer. While a dialog waits the model is blocked on it, so
234
+ the slash commands are matched first: `/status` names the waiting question and
235
+ `/stop` stops the turn, where before both were parsed as an attempt to answer
236
+ and bounced back as “didn’t recognize that”. Any other text is still the answer,
237
+ so a number, 是/否, or 取消 behaves as above.
228
238
  - An unanswered prompt is bounded: a reminder goes out halfway and the question
229
- is cancelled after three minutes with a one-line notice, so an ignored dialog
230
- cannot freeze the session.
239
+ is cancelled at the end of its window with a one-line notice, so an ignored
240
+ dialog cannot freeze the session. The window is three minutes for a dialog and
241
+ ten for the plan review, which arrives with the plan attached — a request that
242
+ carries `timeoutMs` asks for the longer one through the channel, and the
243
+ reminder names 取消 rather than a duration that assumes the short window. A
244
+ timed-out plan review is treated like a dismissal: plan mode ends without
245
+ approval, and the plan file remains.
246
+ - A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
247
+ says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
248
+ timeout line: the peer was not slow, the question left.
231
249
 
232
250
  Each peer session is created with its own UI context (`bindExtensions` with
233
251
  `mode: "rpc"`), so its dialogs go to that peer by construction rather than by
@@ -240,15 +258,19 @@ to WeChat. See [Design notes](#design-notes).
240
258
  ## Outbound files
241
259
 
242
260
  A remote prompt can deliver a local file first. `exit_plan_mode` uses this to
243
- send the plan file itself before the three choices, so WeChat sees the whole plan
261
+ send the plan file itself before the five choices, so WeChat sees the whole plan
244
262
  (a long one falls back to chunked text if the upload fails). The peer session's
245
263
  UI context carries the bridge channel, so `askHuman` finds it and posts the file
246
264
  then the text before the dialogs. Before the choices it also posts a short
247
265
  digest — the title, the step count, and the first steps — so the review is
248
266
  answerable from the chat even if the file bubble is never opened, and a file or
249
267
  digest the bridge cannot deliver is reported on the terminal instead of vanishing.
250
- The choices also include **Send the full plan**, which posts the plan text and
251
- asks again, so the whole plan is reachable without the attachment.
268
+ The digest says plan mode is on and that write access is off. The choices also
269
+ include **Send the full plan**, which posts the plan text and asks again, so the
270
+ whole plan is reachable without the attachment, and **Exit plan mode (discard)**,
271
+ which leaves without approving. After a Keep decision the user can say “exit plan
272
+ mode”; the model calls `cancel_plan_mode`, which needs no plan path and records
273
+ no todo or goal.
252
274
  The flow mirrors the
253
275
  reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
254
276
  (`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
@@ -507,7 +529,7 @@ status, which is what the old start/stop guidance used to do.
507
529
  | `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
508
530
  | `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
509
531
  | `login.ts` | QR login state machine. |
510
- | `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
532
+ | `qrcode.ts` | QR matrix → Unicode half-block terminal lines; the `qrcode` package is imported on first use, not at registration. |
511
533
  | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
512
534
  | `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`). |
513
535
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { WechatError, type WechatClient } from "./client.ts";
20
20
  import { createAdmissionQueue, createTurnBudget } from "./admission.ts";
21
- import { createDeliverer } from "./deliver.ts";
21
+ import { createDeliverer, type MessageTarget } from "./deliver.ts";
22
22
  import { createInboundAdmission } from "./inbound.ts";
23
23
  import { createPollLoop, defaultPollTimeout } from "./poll.ts";
24
24
  import { messageOf } from "./errors.ts";
@@ -225,8 +225,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
225
225
  }
226
226
 
227
227
  /** Send one bubble to a peer and report whether it was delivered. */
228
- async function reply(item: { peer: string; contextToken?: string }, text: string): Promise<boolean> {
229
- return deliverer.reply(item, text, abort?.signal);
228
+ async function reply(target: MessageTarget, text: string): Promise<boolean> {
229
+ return deliverer.reply(target, text, abort?.signal);
230
230
  }
231
231
 
232
232
  /** The outbound surface, reading the live client and account at call time. */
@@ -248,30 +248,34 @@ export function createBridge(deps: BridgeDeps): Bridge {
248
248
  });
249
249
 
250
250
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
251
- async function runSlashAction(command: SlashCommand, item: InboundItem, args: string): Promise<void> {
251
+ async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
252
252
  if (!command.run) return;
253
253
  const ctx: SlashContext = {
254
254
  args,
255
- peer: item.peer,
255
+ peer: target.peer,
256
256
  reply: async (text) => {
257
- await reply(item, text);
257
+ await reply(target, text);
258
258
  },
259
- activeTurn: worker?.state(item.peer).status === "running",
260
- language: languageOf(item.peer),
259
+ activeTurn: worker?.state(target.peer).status === "running",
260
+ language: languageOf(target.peer),
261
261
  session: {
262
262
  newSession: () => {
263
- worker?.reset(item.peer);
263
+ worker?.reset(target.peer);
264
264
  },
265
- stop: () => worker?.abort(item.peer) ?? false,
266
- status: () => worker?.state(item.peer) ?? { status: "idle", queued: 0 },
267
- history: (count) => deps.readHistory(item.peer, count),
265
+ stop: () => worker?.abort(target.peer) ?? false,
266
+ status: () => {
267
+ const current = worker?.state(target.peer) ?? { status: "idle" as const, queued: 0 };
268
+ const waiting = interaction.pendingInfo();
269
+ return waiting?.peer === target.peer ? { ...current, prompt: waiting.title } : current;
270
+ },
271
+ history: (count) => deps.readHistory(target.peer, count),
268
272
  },
269
273
  };
270
274
  try {
271
275
  await command.run(ctx);
272
276
  } catch (error) {
273
277
  notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
274
- await reply(item, `Command /${command.name} failed.`);
278
+ await reply(target, `Command /${command.name} failed.`);
275
279
  }
276
280
  }
277
281
 
@@ -305,11 +309,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
305
309
 
306
310
  /** The peer's language: from this text, else the last one they wrote. */
307
311
  function languageOf(peer: string, text?: string): PeerLanguage {
308
- if (text) {
309
- const language = peerLanguage(text);
310
- languages.set(peer, language);
311
- return language;
312
- }
312
+ // A Chinese message is evidence of Chinese. Its absence is not evidence of
313
+ // English, or a short reply ("ok", "1") would flip an established conversation
314
+ // back to English; English is the fallback, so it needs no record of its own.
315
+ if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
313
316
  return languages.get(peer) ?? "en";
314
317
  }
315
318
 
@@ -386,26 +389,11 @@ export function createBridge(deps: BridgeDeps): Bridge {
386
389
  if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
387
390
  deps.saveState(state);
388
391
 
389
- // A reply to a pending prompt is consumed here, before queueing, so it never
390
- // starts a turn of its own. A duplicate answer was stopped by the id check.
391
- if (interaction.handleInbound(peer, text)) return;
392
- const language = languageOf(peer, text);
393
- const item: InboundItem = {
394
- peer,
395
- contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken,
396
- text,
397
- media,
398
- language,
399
- };
400
- if (!text && media.length === 0) {
401
- // Nothing to read. A voice note without a transcript or a video is
402
- // answered, so the peer knows it did not reach the agent.
403
- if (unsupported > 0) void reply(item, notice("unsupported", language));
404
- return;
405
- }
406
- // Intercept a text slash command before it becomes a turn. A message with
407
- // media is never a command, so an attachment is never dropped; an unknown
408
- // command falls through to the model as ordinary text.
392
+ const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
393
+ // A command is not an answer: while a dialog waits the model is blocked on it,
394
+ // so /stop and /status must reach their handlers or the peer's only way out is
395
+ // the prompt timing out. A message with media is never a command, and an
396
+ // unknown command falls through to the model as ordinary text.
409
397
  const slash = media.length === 0 ? parseSlash(text) : undefined;
410
398
  const command = slash ? findSlashCommand(slashCommands, slash.name) : undefined;
411
399
  if (command && slash) {
@@ -415,12 +403,22 @@ export function createBridge(deps: BridgeDeps): Bridge {
415
403
  }
416
404
  if (command.kind === "snippet") {
417
405
  text = expandSnippet(command, slash.args);
418
- item.text = text;
419
406
  } else {
420
- void runSlashAction(command, item, slash.args);
407
+ void runSlashAction(command, target, slash.args);
421
408
  return;
422
409
  }
423
410
  }
411
+ // A reply to a pending prompt is consumed here, before queueing, so it never
412
+ // starts a turn of its own. A duplicate answer was stopped by the id check.
413
+ if (interaction.handleInbound(peer, text)) return;
414
+ const language = languageOf(peer, text);
415
+ const item: InboundItem = { ...target, text, media, language };
416
+ if (!text && media.length === 0) {
417
+ // Nothing to read. A voice note without a transcript or a video is
418
+ // answered, so the peer knows it did not reach the agent.
419
+ if (unsupported > 0) void reply(item, notice("unsupported", language));
420
+ return;
421
+ }
424
422
  // A slot is reserved here, before the download, so a burst of media cannot
425
423
  // all pass the check and then enqueue together.
426
424
  if (!budget.allow(peer)) {
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { randomUUID } from "node:crypto";
12
+ import { asNumber } from "../../lib/config.ts";
12
13
  import { mediaDownloadUrl } from "./media.ts";
13
14
  import type {
14
15
  BotCredentials,
@@ -120,10 +121,6 @@ function parseJson(text: string): Record<string, unknown> {
120
121
  }
121
122
  }
122
123
 
123
- function asNumber(value: unknown): number | undefined {
124
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
125
- }
126
-
127
124
  function asString(value: unknown): string | undefined {
128
125
  return typeof value === "string" && value !== "" ? value : undefined;
129
126
  }
@@ -292,7 +292,7 @@ async function runLogin(ctx: ExtensionContext, deps: CommandDeps): Promise<void>
292
292
  return;
293
293
  }
294
294
 
295
- const state: LoginViewState = { status: "wait", lines: qrLines(qr.imgContent), url: qr.imgContent };
295
+ const state: LoginViewState = { status: "wait", lines: await qrLines(qr.imgContent), url: qr.imgContent };
296
296
  const result = await ctx.ui.custom<LoginOutcome | undefined>((tui, theme, _keybindings, done) => {
297
297
  const controller = new AbortController();
298
298
  void (async () => {
@@ -43,6 +43,11 @@ export interface WechatInteractionDeps {
43
43
  }
44
44
 
45
45
  export interface WechatInteractionChannel extends InteractionChannel {
46
+ /**
47
+ * Widen the bound for the prompts asked inside the scope, and restore the
48
+ * previous one when it ends: the optional channel member, made concrete here.
49
+ */
50
+ setPromptTimeout(ms: number): () => void;
46
51
  /** Consume an inbound message as a reply; true when it was one. */
47
52
  handleInbound(peer: string, text: string): boolean;
48
53
  /** Whether a prompt is currently waiting for a reply. */
@@ -79,6 +84,11 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
79
84
  const arm = deps.armTimer ?? defaultTimer;
80
85
  const now = deps.now ?? Date.now;
81
86
  let pending: Pending | undefined;
87
+ /**
88
+ * The bound for the prompts asked inside the current `setPromptTimeout` scope,
89
+ * set by a caller whose dialog needs reading time before it can be answered.
90
+ */
91
+ let scopedTimeoutMs: number | undefined;
82
92
  /**
83
93
  * Free text captured from a `select` "Other" reply, per peer: the immediately
84
94
  * following `input` for that peer returns it. Keyed so one peer's free text can
@@ -119,9 +129,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
119
129
 
120
130
  cancel(): void {
121
131
  if (!pending) return;
122
- // The peer saw the prompt; one line says it was withdrawn. `reset` is the
132
+ // The peer saw the prompt; one line says it was withdrawn, which is not the
133
+ // same as expiry — they were not slow, the question went away. `reset` is the
123
134
  // silent cleanup path and deliberately does not send this.
124
- void deps.send(notice("timeout", deps.language()));
135
+ void deps.send(notice("withdrawn", deps.language()));
125
136
  settle({ kind: "cancelled" });
126
137
  },
127
138
 
@@ -133,6 +144,14 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
133
144
  return deps.sendFile(path, name, signal);
134
145
  },
135
146
 
147
+ setPromptTimeout(ms: number): () => void {
148
+ const previous = scopedTimeoutMs;
149
+ scopedTimeoutMs = ms;
150
+ return () => {
151
+ scopedTimeoutMs = previous;
152
+ };
153
+ },
154
+
136
155
  async request(request: InteractionRequest, signal?: AbortSignal): Promise<InteractionAnswer> {
137
156
  // A `select` that resolved to "Other" stashes its text for that peer; the
138
157
  // immediately following `input` from the same peer returns it without
@@ -158,9 +177,10 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
158
177
  signal?.addEventListener("abort", onAbort, { once: true });
159
178
  // An ignored question must not freeze the session: nudge once, then
160
179
  // cancel so the model gets a clean answer instead of a hang.
161
- cancelTimers.push(arm(PROMPT_TIMEOUT_MS / 2, () => void deps.send(notice("promptWaiting", deps.language()))));
180
+ const bound = scopedTimeoutMs ?? PROMPT_TIMEOUT_MS;
181
+ cancelTimers.push(arm(bound / 2, () => void deps.send(notice("promptWaiting", deps.language()))));
162
182
  cancelTimers.push(
163
- arm(PROMPT_TIMEOUT_MS, () => {
183
+ arm(bound, () => {
164
184
  void deps.send(notice("timeout", deps.language()));
165
185
  settle({ kind: "cancelled" });
166
186
  }),
@@ -197,6 +217,7 @@ export function createWechatInteractionChannel(deps: WechatInteractionDeps): Wec
197
217
 
198
218
  reset(): void {
199
219
  other.clear();
220
+ scopedTimeoutMs = undefined;
200
221
  settle({ kind: "cancelled" });
201
222
  },
202
223
  };
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { type Component, visibleWidth } from "@earendil-works/pi-tui";
11
- import { screenHint } from "../../lib/tui/chrome.ts";
11
+ import { screenFooter, screenHint } from "../../lib/tui/chrome.ts";
12
12
  import { clip } from "../../lib/tui/columns.ts";
13
13
  import { isCloseKey } from "../../lib/tui/cursor.ts";
14
14
  import { titleRule } from "../../lib/tui/rules.ts";
@@ -87,8 +87,7 @@ export class LoginScreenComponent implements Component {
87
87
  titleRule(theme, target, "wechat · login"),
88
88
  "",
89
89
  ...loginBody(this.options.state(), target),
90
- "",
91
- screenHint(theme, target, ["Esc cancel"]),
90
+ ...screenFooter(theme, target, screenHint(theme, target, ["Esc cancel"])),
92
91
  ];
93
92
  }
94
93
  }
@@ -22,18 +22,19 @@ export type NoticeKind =
22
22
  | "budget"
23
23
  | "promptWaiting"
24
24
  | "timeout"
25
+ | "withdrawn"
25
26
  | "failed"
26
27
  | "newBusy";
27
28
 
28
29
  const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
29
- ack: { zh: "收到,正在处理…", en: "Got it — working on it." },
30
+ ack: { zh: "收到,正在排队,轮到就回你。", en: "Got it — queued; I'll answer when it's my turn." },
30
31
  busy: {
31
32
  zh: "前面还有一条正在处理,你的消息排在后面,轮到你就回你。",
32
33
  en: "Your message is queued behind another conversation; I'll answer as soon as it's free.",
33
34
  },
34
35
  stillWorking: {
35
- zh: "还在处理这条,稍等一下…",
36
- en: "Still working on this one — a moment…",
36
+ zh: "还在处理这条,发送 /stop 可取消。",
37
+ en: "Still working — send /stop to cancel.",
37
38
  },
38
39
  overflow: { zh: "消息有点多,稍后再发。", en: "Too many messages queued — please wait a moment." },
39
40
  media: {
@@ -58,10 +59,11 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
58
59
  en: "You've reached this hour's message limit — please try again later.",
59
60
  },
60
61
  promptWaiting: {
61
- zh: "还在等你的回复,约 1 分钟后超时。",
62
- en: "Still waiting for your reply — this expires in about a minute.",
62
+ zh: "还在等你的回复,回复「取消」可跳过。",
63
+ en: "Still waiting for your reply — reply “cancel” to skip this.",
63
64
  },
64
65
  timeout: { zh: "问题超时,已取消。", en: "That question timed out." },
66
+ withdrawn: { zh: "问题已撤回。", en: "That question was withdrawn." },
65
67
  failed: { zh: "处理失败,请稍后重试。", en: "Something went wrong — please try again." },
66
68
  newBusy: {
67
69
  zh: "正在处理当前消息,稍后再发送 /new。",
@@ -20,9 +20,12 @@ export type PromptParse =
20
20
  | { kind: "cancelled" }
21
21
  | { kind: "retry" };
22
22
 
23
- const SELECT_HINT =
23
+ const SELECT_OTHER_HINT =
24
24
  "回复编号或选项文字;其它内容作为自定义回答;回复「取消」或 cancel 跳过。\n" +
25
25
  "Reply with a number or the option text; anything else becomes your own answer; reply cancel to dismiss.";
26
+ const SELECT_CHOICE_HINT =
27
+ "回复编号或选项文字;回复「取消」或 cancel 跳过。\n" +
28
+ "Reply with a number or the option text; reply cancel to dismiss.";
26
29
  const CONFIRM_HINT = "回复「是」或「否」。\nReply yes or no.";
27
30
  const INPUT_HINT = "直接回复内容;回复「取消」或 cancel 跳过。\nReply with the text; reply cancel to skip.";
28
31
  const EDITOR_HINT =
@@ -32,7 +35,7 @@ const EDITOR_HINT =
32
35
  export const RETRY_NOTICE =
33
36
  "没有识别到有效回复,请重新回答。\nSorry, I didn't recognize that reply — please answer again.";
34
37
 
35
- const CANCEL_WORDS = new Set(["取消", "cancel", "quit", "exit"]);
38
+ const CANCEL_WORDS = new Set(["取消", "退出", "cancel", "quit", "exit"]);
36
39
  const YES_WORDS = new Set(["是", "y", "yes", "1", "确认", "确定", "行", "ok", "好", "对"]);
37
40
  const NO_WORDS = new Set(["否", "n", "no", "0", "不", "不是", "不行"]);
38
41
 
@@ -50,7 +53,8 @@ function promptBody(request: InteractionRequest): string {
50
53
  switch (request.kind) {
51
54
  case "select": {
52
55
  const options = (request.options ?? []).map((label, index) => `${index + 1}. ${label}`);
53
- return lines([request.title, ...options, SELECT_HINT]);
56
+ const hasOther = (request.options ?? []).includes(INTERACTION_OTHER_LABEL);
57
+ return lines([request.title, ...options, hasOther ? SELECT_OTHER_HINT : SELECT_CHOICE_HINT]);
54
58
  }
55
59
  case "confirm":
56
60
  return lines([request.title, request.message, CONFIRM_HINT]);
@@ -61,12 +65,23 @@ function promptBody(request: InteractionRequest): string {
61
65
  }
62
66
  }
63
67
 
68
+ /**
69
+ * Cancel intent, including the phrases a phone user naturally types.
70
+ * Exact words keep the old meaning; the two patterns cover “退出 plan 模式” and
71
+ * “exit plan mode” without treating a sentence such as “exit code” as a cancel.
72
+ */
73
+ function isCancelIntent(text: string, lower: string): boolean {
74
+ if (CANCEL_WORDS.has(lower)) return true;
75
+ if (/^(退出|离开)\s*(计划|plan)/i.test(text)) return true;
76
+ return /^(exit|leave)\s+(the\s+)?plan\b/i.test(text);
77
+ }
78
+
64
79
  /** Interpret a WeChat reply to `request`. */
65
80
  export function parsePromptAnswer(request: InteractionRequest, raw: string): PromptParse {
66
81
  const text = sanitizeInbound(raw);
67
82
  const lower = text.toLowerCase();
68
83
 
69
- if (lower !== "" && CANCEL_WORDS.has(lower)) {
84
+ if (isCancelIntent(text, lower)) {
70
85
  return request.kind === "confirm" ? { kind: "confirmed", confirmed: false } : { kind: "cancelled" };
71
86
  }
72
87