@gavin-hu/my-pi-agent 0.13.1 → 0.15.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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { StringEnum } from "@earendil-works/pi-ai";
11
11
  import { Type, type Static } from "typebox";
12
+ import { optionalString } from "../../lib/config.ts";
12
13
  import { WATCH_ACTIONS, type WatchAction } from "./types.ts";
13
14
 
14
15
  /** Longest accepted label. */
@@ -79,10 +80,6 @@ export type WatchCall =
79
80
  | { action: "stop"; id: string }
80
81
  | { action: "clear"; id?: string; all?: boolean };
81
82
 
82
- function optionalString(value: unknown): string | undefined {
83
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
84
- }
85
-
86
83
  function assertRegex(value: string, field: string): void {
87
84
  if (value.length > MAX_PATTERN) throw new Error(`${field} is longer than ${MAX_PATTERN} characters.`);
88
85
  try {
@@ -9,6 +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
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
13
14
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
14
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
@@ -32,17 +33,6 @@ export const TOOL_NAME = "watch";
32
33
  /** Collapsed transcript rows before the result is truncated. */
33
34
  const RESULT_ROWS = 8;
34
35
 
35
- function errorResult(
36
- action: WatchDetails["action"],
37
- message: string,
38
- ): { content: Array<{ type: "text"; text: string }>; details: WatchDetails; isError: true } {
39
- return {
40
- content: [{ type: "text", text: `Error: ${message}` }],
41
- details: { action, error: message },
42
- isError: true,
43
- };
44
- }
45
-
46
36
  const GUIDELINES = [
47
37
  "Use `watch add` to tail a log file or a long-running command and surface lines matching a pattern.",
48
38
  "Read matches with `watch matches <id>`; use `watch logs <id>` for the raw output tail.",
@@ -6,36 +6,23 @@
6
6
  * tracks the selection by id so a live re-sort cannot move the cursor, through
7
7
  * the shared `IdCursor` and `LinePane`.
8
8
  *
9
- * The screen is drawn from the design system: a label-first `titleRule`, one
10
- * `listRow` grammar, and `emptyState`. Matched lines arrive already sanitized by
11
- * the runtime, so no untrusted escape sequence reaches the terminal here.
9
+ * The frame, the navigation keys, the y/N confirmation, and the matches pane come
10
+ * from the shared `DockListScreen`, so this module owns only the watch
11
+ * vocabulary: the row, the focus block, the counts, the hints, and what the
12
+ * verbs do. Matched lines arrive already sanitized by the runtime, so no
13
+ * untrusted escape sequence reaches the terminal here.
12
14
  */
13
15
 
14
16
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
16
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
17
- import { clip, fitCells } from "../../lib/tui/columns.ts";
18
- import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
19
- import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
20
- import { LinePane } from "../../lib/tui/line-pane.ts";
21
- import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
22
- import { titleRule } from "../../lib/tui/rules.ts";
17
+ import type { Component } from "@earendil-works/pi-tui";
18
+ import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
19
+ import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
20
+ import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
21
+ import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
23
22
  import { paint } from "../../lib/tui/tokens.ts";
24
23
  import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
25
24
  import type { WatchRecord } from "./types.ts";
26
25
 
27
- /** Rows the `/watch` screen body shows when the terminal height is unknown. */
28
- const SCREEN_DEFAULT_ROWS = 14;
29
-
30
- /** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
31
- const LIST_CHROME_ROWS = 7;
32
-
33
- /** Fixed rows around the matches body: header, subtitle, blank, blank, hint, blank. */
34
- const LINES_CHROME_ROWS = 6;
35
-
36
- /** A watcher's stable identity, for the id-anchored cursor. */
37
- const watchId = (watch: WatchRecord): string => watch.id;
38
-
39
26
  /** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
40
27
  function watchRow(
41
28
  watch: WatchRecord,
@@ -48,6 +35,7 @@ function watchRow(
48
35
  glyph: statusGlyphChar(watch.status),
49
36
  glyphRole: statusRole(watch.status),
50
37
  label: `${watch.id} ${shortLabel(watch)}`,
38
+ labelRole: options.selected ? "accent" : "label",
51
39
  trailing: `${watch.matchCount}`,
52
40
  });
53
41
  }
@@ -57,48 +45,41 @@ export type WatchResultInput =
57
45
  | { kind: "list"; watches: WatchRecord[]; more: number }
58
46
  | { kind: "lines"; label: string; lines: string[]; earlier: number };
59
47
 
48
+ /** Map the watch vocabulary onto the shared transcript tail block. */
49
+ function watchTail(input: WatchResultInput): TailResultInput<WatchRecord> {
50
+ if (input.kind === "lines") {
51
+ return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: input.label };
52
+ }
53
+ return {
54
+ kind: "list",
55
+ rows: input.watches,
56
+ more: input.more,
57
+ rowLine: (watch, width, theme) => watchRow(watch, theme, width, { marker: false }),
58
+ };
59
+ }
60
+
60
61
  /**
61
- * Transcript result block for the `watch` tool: a blank line, then either the
62
- * `/watch`-style row rail (already ordered and capped by the caller) or the
63
- * themed matched-line tail. Reused across renders via `setInput`.
62
+ * Transcript result block for the `watch` tool: the shared tail block over the
63
+ * `/watch`-style row rail or the themed matched-line tail. Reused across renders.
64
64
  */
65
65
  export class WatchResult implements Component {
66
- private input: WatchResultInput;
67
- private theme: Theme;
66
+ private readonly view: TailResult<WatchRecord>;
68
67
 
69
68
  constructor(input: WatchResultInput, theme: Theme) {
70
- this.input = input;
71
- this.theme = theme;
69
+ this.view = new TailResult(watchTail(input), theme);
72
70
  }
73
71
 
74
72
  /** Update in place so the transcript can reuse this component across renders. */
75
73
  setInput(input: WatchResultInput, theme: Theme): void {
76
- this.input = input;
77
- this.theme = theme;
74
+ this.view.setInput(watchTail(input), theme);
78
75
  }
79
76
 
80
- invalidate(): void {}
77
+ invalidate(): void {
78
+ this.view.invalidate();
79
+ }
81
80
 
82
81
  render(width: number): string[] {
83
- const w = Math.max(1, width);
84
- // A leading blank line separates the result from the call header.
85
- const lines: string[] = [""];
86
- if (this.input.kind === "list") {
87
- for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w, { marker: false }));
88
- if (this.input.more > 0) {
89
- const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
90
- lines.push(clip(` ${note}`, w));
91
- }
92
- } else {
93
- if (this.input.earlier > 0) {
94
- const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`;
95
- lines.push(clip(note, w));
96
- }
97
- for (const line of this.input.lines) {
98
- lines.push(this.theme.fg("toolOutput", clip(line, w)));
99
- }
100
- }
101
- return lines;
82
+ return this.view.render(width);
102
83
  }
103
84
  }
104
85
 
@@ -109,265 +90,114 @@ export interface WatchListCallbacks {
109
90
  clear(): void;
110
91
  }
111
92
 
112
- /** A destructive action waiting for y/N confirmation. */
113
- type PendingConfirm = { kind: "stop"; id: string; label: string } | { kind: "clear"; count: number };
114
-
115
93
  /** Dismissible `/watch` screen: a selectable list with a focus pane and a matches view. */
116
- export class WatchListComponent implements Component {
117
- private mode: "list" | "lines" = "list";
118
- /** Focus anchored to a watcher id; the list is re-sorted from live state each render. */
119
- private readonly cursor = new IdCursor();
120
- /** The open matches tail. */
121
- private readonly pane = new LinePane();
122
- private confirm: PendingConfirm | undefined;
123
- private notice: string | undefined;
124
-
94
+ export class WatchListComponent extends DockListScreen<WatchRecord> {
125
95
  constructor(
126
96
  private readonly watches: () => WatchRecord[],
127
- private readonly theme: Theme,
97
+ theme: Theme,
128
98
  private readonly callbacks: WatchListCallbacks,
129
- private readonly onClose: () => void,
130
- private readonly requestRender: () => void,
131
- private readonly viewportRowsSource?: ViewportRowsSource,
132
- ) {}
133
-
134
- invalidate(): void {}
99
+ onClose: () => void,
100
+ requestRender: () => void,
101
+ viewportRowsSource?: ViewportRowsSource,
102
+ ) {
103
+ super({
104
+ theme,
105
+ title: "Watch",
106
+ detailTitle: "Watch Matches",
107
+ emptyList: "No watchers.",
108
+ emptyDetail: "No matches yet.",
109
+ requestRender,
110
+ onClose,
111
+ viewportRowsSource,
112
+ });
113
+ }
135
114
 
136
115
  /** Replace the matches pane contents (called by the command's poll loop). */
137
116
  setLines(title: string, lines: string[], meta: { more?: boolean } = {}): void {
138
- if (this.pane.update({ title, lines, more: meta.more })) this.requestRender();
117
+ this.setDetail({ title, lines, more: meta.more });
139
118
  }
140
119
 
141
120
  /** The watcher whose matches pane is open, or undefined in list mode. */
142
121
  currentLineId(): string | undefined {
143
- return this.mode === "lines" && this.pane.id ? this.pane.id : undefined;
122
+ return this.currentDetailId();
144
123
  }
145
124
 
146
125
  /** Re-read the open matches pane from the runtime. */
147
126
  refreshLines(id: string): void {
148
- const result = this.callbacks.lines(id);
149
- if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
127
+ this.refreshDetail(id);
150
128
  }
151
129
 
152
- private get ordered(): WatchRecord[] {
130
+ protected rows(): readonly WatchRecord[] {
153
131
  return [...this.watches()].sort(compareWatches);
154
132
  }
155
133
 
156
- /** Focus watcher `next` (clamped) and keep it visible. */
157
- private select(next: number): void {
158
- const ordered = this.ordered;
159
- if (ordered.length === 0) return;
160
- this.cursor.select(ordered, watchId, next);
161
- this.requestRender();
162
- }
163
-
164
- private openLines(): void {
165
- const watch = this.ordered[this.cursor.indexOf(this.ordered, watchId)];
166
- if (!watch) return;
167
- this.mode = "lines";
168
- const result = this.callbacks.lines(watch.id);
169
- this.pane.open(watch.id, {
170
- title: `${watch.id} ${shortLabel(watch)}`,
171
- lines: result?.lines ?? [],
172
- more: result?.more ?? false,
173
- });
174
- this.requestRender();
134
+ protected idOf(watch: WatchRecord): string {
135
+ return watch.id;
175
136
  }
176
137
 
177
- handleInput(data: string): void {
178
- if (this.confirm) {
179
- this.handleConfirmInput(data);
180
- return;
181
- }
182
- if (this.mode === "list") this.handleListInput(data);
183
- else this.handleLinesInput(data);
138
+ protected rowLine(watch: WatchRecord, width: number, selected: boolean): string {
139
+ return watchRow(watch, this.theme, width, { selected });
184
140
  }
185
141
 
186
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
187
- const delta = wheelDelta(event);
188
- if (delta === undefined) return undefined;
189
- if (this.confirm) return { handled: true };
190
- if (this.mode === "list") {
191
- this.select(this.cursor.indexOf(this.ordered, watchId) + delta);
192
- } else {
193
- this.pane.setScroll(this.pane.scroll + delta);
194
- this.requestRender();
195
- }
196
- return { handled: true };
197
- }
198
-
199
- private handleListInput(data: string): void {
200
- if (isCloseKey(data) || data === "q") {
201
- this.onClose();
202
- return;
203
- }
204
- if (this.notice !== undefined) {
205
- this.notice = undefined;
206
- this.requestRender();
207
- }
208
- const ordered = this.ordered;
209
- const index = this.cursor.indexOf(ordered, watchId);
210
- switch (navIntent(data)) {
211
- case "up":
212
- this.select(index - 1);
213
- return;
214
- case "down":
215
- this.select(index + 1);
216
- return;
217
- case "pageUp":
218
- this.select(index - this.cursor.visible);
219
- return;
220
- case "pageDown":
221
- this.select(index + this.cursor.visible);
222
- return;
223
- case "home":
224
- this.select(0);
225
- return;
226
- case "end":
227
- this.select(ordered.length - 1);
228
- return;
229
- }
230
- if (matchesKey(data, Key.enter) || data === "l") {
231
- this.openLines();
232
- } else if (data === "x" || data === "K") {
233
- const watch = ordered[index];
234
- if (!watch) return;
235
- if (watch.status === "running") {
236
- this.confirm = { kind: "stop", id: watch.id, label: shortLabel(watch) };
237
- } else {
238
- this.notice = `${watch.id} is ${watch.status}; nothing to stop.`;
239
- }
240
- this.requestRender();
241
- } else if (data === "d") {
242
- const stopped = ordered.filter((watch) => watch.status !== "running");
243
- if (stopped.length > 0) this.confirm = { kind: "clear", count: stopped.length };
244
- else this.notice = "No stopped watchers to clear.";
245
- this.requestRender();
246
- }
142
+ protected detailBlock(watch: WatchRecord): string[] {
143
+ return formatWatchDetail(watch, Date.now());
247
144
  }
248
145
 
249
- private handleConfirmInput(data: string): void {
250
- const pending = this.confirm;
251
- if (!pending) return;
252
- if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
253
- this.confirm = undefined;
254
- if (pending.kind === "stop") this.callbacks.stop(pending.id);
255
- else this.callbacks.clear();
256
- this.cursor.clamp(this.ordered, watchId);
257
- this.requestRender();
258
- return;
259
- }
260
- if (data === "n" || data === "N" || isCloseKey(data) || data === "q") {
261
- this.confirm = undefined;
262
- this.requestRender();
263
- }
146
+ protected summaryCells(rows: readonly WatchRecord[]): string[] {
147
+ const cells = [paint(this.theme, "meta", `${rows.length} watcher${rows.length === 1 ? "" : "s"}`)];
148
+ const running = rows.filter((watch) => watch.status === "running").length;
149
+ const matched = rows.reduce((sum, watch) => sum + watch.matchCount, 0);
150
+ if (running > 0) cells.push(paint(this.theme, "meta", `${running} running`));
151
+ if (matched > 0) cells.push(paint(this.theme, "accent", `${matched} matches`));
152
+ return cells;
264
153
  }
265
154
 
266
- private confirmPrompt(): string {
267
- const pending = this.confirm;
268
- if (!pending) return "";
269
- if (pending.kind === "stop") return `Stop ${pending.id} (${pending.label})? y/N`;
270
- return `Clear ${pending.count} stopped watcher${pending.count === 1 ? "" : "s"}? y/N`;
155
+ protected listHints(): ScreenHint[] {
156
+ return [
157
+ "Esc close",
158
+ "↑/↓ or j/k select",
159
+ { text: "Enter or l matches", color: "accent" },
160
+ { text: "x or K stop", color: "warning" },
161
+ { text: "d clear stopped", color: "warning" },
162
+ "PgUp/PgDn",
163
+ ];
271
164
  }
272
165
 
273
- private handleLinesInput(data: string): void {
274
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
275
- this.mode = "list";
276
- this.pane.close();
277
- this.requestRender();
278
- return;
279
- }
280
- if (this.pane.handleKey(data)) this.requestRender();
166
+ protected detailHints(): ScreenHint[] {
167
+ return ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"];
281
168
  }
282
169
 
283
- private summary(watches: WatchRecord[], width: number): string {
284
- const cells = [paint(this.theme, "meta", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`)];
285
- const running = watches.filter((watch) => watch.status === "running").length;
286
- const matched = watches.reduce((sum, watch) => sum + watch.matchCount, 0);
287
- if (running > 0) cells.push(paint(this.theme, "meta", `${running} running`));
288
- if (matched > 0) cells.push(paint(this.theme, "accent", `${matched} matches`));
289
- const separator = paint(this.theme, "meta", SEPARATORS.item);
290
- return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
170
+ protected detailFor(watch: WatchRecord): DetailContents {
171
+ const result = this.callbacks.lines(watch.id);
172
+ return {
173
+ title: `${watch.id} ${shortLabel(watch)}`,
174
+ lines: result?.lines ?? [],
175
+ more: result?.more ?? false,
176
+ };
291
177
  }
292
178
 
293
- render(width: number): string[] {
294
- const w = Math.max(1, width);
295
- return this.mode === "lines" ? this.renderLines(w) : this.renderList(w);
179
+ protected detailById(id: string): { lines: string[]; more?: boolean } | undefined {
180
+ return this.callbacks.lines(id);
296
181
  }
297
182
 
298
- private renderList(w: number): string[] {
299
- const watches = this.ordered;
300
- this.cursor.clamp(watches, watchId);
301
- const index = this.cursor.indexOf(watches, watchId);
302
- const focused = watches[index];
303
- const detail = focused ? formatWatchDetail(focused, Date.now()) : [];
304
- this.cursor.visible = fitRows(
305
- this.viewportRowsSource,
306
- watches.length,
307
- LIST_CHROME_ROWS + detail.length,
308
- SCREEN_DEFAULT_ROWS,
309
- );
310
- this.cursor.clamp(watches, watchId);
311
-
312
- const body: string[] = [];
313
- let range: string | undefined;
314
- if (watches.length === 0) {
315
- body.push(...emptyState(this.theme, w, { label: "No watchers." }));
316
- } else {
317
- const end = Math.min(watches.length, this.cursor.scroll + this.cursor.visible);
318
- for (let i = this.cursor.scroll; i < end; i++)
319
- body.push(watchRow(watches[i], this.theme, w, { selected: i === index }));
320
- if (this.cursor.scroll > 0 || end < watches.length) {
321
- range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: watches.length });
183
+ protected handleVerb(key: string, watch: WatchRecord | undefined): void {
184
+ if (key === "x" || key === "K") {
185
+ if (!watch) return;
186
+ if (watch.status === "running") {
187
+ this.confirm(`Stop ${watch.id} (${shortLabel(watch)})? y/N`, () => this.callbacks.stop(watch.id));
188
+ } else {
189
+ this.notify(`${watch.id} is ${watch.status}; nothing to stop.`);
322
190
  }
191
+ return;
323
192
  }
324
-
325
- const hint = this.confirm
326
- ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
327
- : this.notice
328
- ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
329
- : screenHint(this.theme, w, [
330
- "Esc close",
331
- "↑/↓ or j/k select",
332
- "Enter or l matches",
333
- "x or K stop",
334
- "d clear stopped",
335
- "PgUp/PgDn",
336
- ]);
337
-
338
- return dockListFrame({
339
- header: titleRule(this.theme, w, "Watch"),
340
- summary: this.summary(watches, w),
341
- body,
342
- range,
343
- // The detail pane is separated from the list by a blank line.
344
- detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
345
- hint,
346
- });
347
- }
348
-
349
- private renderLines(w: number): string[] {
350
- const visible = fitRows(this.viewportRowsSource, this.pane.count, LINES_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
351
- this.pane.fit(visible);
352
-
353
- const body: string[] = [];
354
- let range: string | undefined;
355
- if (this.pane.count === 0) {
356
- body.push(...emptyState(this.theme, w, { label: "No matches yet." }));
357
- } else {
358
- const { start, end } = this.pane.window();
359
- for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
360
- if (start > 0 || end < this.pane.count) {
361
- range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
362
- }
193
+ if (key !== "d") return;
194
+ const stopped = this.rows().filter((row) => row.status !== "running");
195
+ if (stopped.length === 0) {
196
+ this.notify("No stopped watchers to clear.");
197
+ return;
363
198
  }
364
-
365
- return dockListFrame({
366
- header: titleRule(this.theme, w, this.pane.title("Watch Matches")),
367
- summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
368
- body,
369
- range,
370
- hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
371
- });
199
+ this.confirm(`Clear ${stopped.length} stopped watcher${stopped.length === 1 ? "" : "s"}? y/N`, () =>
200
+ this.callbacks.clear(),
201
+ );
372
202
  }
373
203
  }
@@ -2,13 +2,11 @@
2
2
  * Presentation for the watch extension: the footer status chips and the match
3
3
  * alert.
4
4
  *
5
- * Kept separate from the runtime so the runtime owns lifecycle (the watcher
6
- * table, the tail clock) and this owns "how watchers are shown". The chips come
7
- * from the shared `lib/tui/status-controller.ts`, so only the vocabulary — the
8
- * keys, glyphs, and counts — is watch-specific.
5
+ * The chips and the context they paint through come from the shared
6
+ * `lib/tui/status-controller.ts`, so only the vocabulary — the keys, glyphs,
7
+ * counts, and message text — is watch-specific.
9
8
  */
10
9
 
11
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
10
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
13
11
  import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
14
12
  import type { WatchConfig } from "./config.ts";
@@ -28,8 +26,6 @@ export interface UiOptions {
28
26
 
29
27
  export function createUiController(options: UiOptions): UiController {
30
28
  const { getWatches, getUnseenMatches, getConfig } = options;
31
- // The match alert also needs the context, so it is mirrored here.
32
- let ctx: ExtensionContext | undefined;
33
29
 
34
30
  const controller = createStatusController({
35
31
  keys: [STATUS_KEYS.watch, STATUS_KEYS.watchMatches],
@@ -45,31 +41,8 @@ export function createUiController(options: UiOptions): UiController {
45
41
  });
46
42
 
47
43
  const notifyMatch = (watch: WatchRecord, line: string): void => {
48
- const target = ctx;
49
- if (!target) return;
50
- try {
51
- target.ui.notify(`watch ${watch.id} (${shortLabel(watch)}) matched: ${line}`, "warning");
52
- } catch {
53
- // UI may be unavailable in non-interactive modes.
54
- }
44
+ controller.notify(`watch ${watch.id} (${shortLabel(watch)}) matched: ${line}`, "warning");
55
45
  };
56
46
 
57
- return {
58
- attach: (next) => {
59
- ctx = next;
60
- controller.attach(next);
61
- },
62
- detach: () => {
63
- ctx = undefined;
64
- controller.detach();
65
- },
66
- paint: () => {
67
- controller.paint();
68
- },
69
- setStatus: (next) => {
70
- if (next) ctx = next;
71
- controller.setStatus(next);
72
- },
73
- notifyMatch,
74
- };
47
+ return { ...controller, notifyMatch };
75
48
  }
@@ -371,7 +371,7 @@ the content type and size.
371
371
  | `fetch/page.ts` | Fetch + redirect-following + content-type routing |
372
372
  | `fetch/ssrf.ts` | URL/host validation and private-address blocking |
373
373
  | `fetch/extract.ts` | HTML → title + readable text |
374
- | `fetch/pdf.ts` | Optional PDF text extraction via lazy `unpdf` (injected via `FetchDeps`) |
374
+ | `fetch/page.ts` | Fetch + redirect-following + content-type routing; PDF extraction is injected via `FetchDeps` from the shared `lib/pdf.ts` |
375
375
  | `fetch/find.ts` | Passage search (exact/insensitive/fuzzy) |
376
376
  | `fetch/cache.ts` | Per-registration LRU/TTL page cache (`createPageCache`) |
377
377
  | `fetch/format.ts`, `fetch/types.ts` | Result text and data types |
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { WebFetchConfig } from "./config.ts";
13
13
  import { extractReadable } from "./extract.ts";
14
- import { extractPdfText, type PdfExtractor } from "./pdf.ts";
14
+ import { extractPdfText, type PdfExtractor } from "../../../lib/pdf.ts";
15
15
  import { createFetchRunner, type HttpRunner, type HttpResponse } from "../http.ts";
16
16
  import { decodeHtml } from "../charset.ts";
17
17
  import { assertAllowedUrl } from "./ssrf.ts";
@@ -149,7 +149,11 @@ function pageResult(loaded: LoadedPage, request: FetchRequest): { response: Fetc
149
149
  }
150
150
 
151
151
  /** Build the response entry and text for a failed URL. */
152
- function errorResult(url: string, request: FetchRequest, message: string): { response: FetchResponse; text: string } {
152
+ function fetchErrorResult(
153
+ url: string,
154
+ request: FetchRequest,
155
+ message: string,
156
+ ): { response: FetchResponse; text: string } {
153
157
  return {
154
158
  response: {
155
159
  url,
@@ -209,7 +213,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
209
213
  ({ response: page, text } = pageResult(loaded, request));
210
214
  } catch (error) {
211
215
  const message = error instanceof Error ? error.message : String(error);
212
- ({ response: page, text } = errorResult(request.url, request, message));
216
+ ({ response: page, text } = fetchErrorResult(request.url, request, message));
213
217
  }
214
218
 
215
219
  return {
@@ -7,6 +7,7 @@
7
7
  * object created per extension instance, so there is no module-level state.
8
8
  */
9
9
 
10
+ import { messageOf } from "../../../lib/errors.ts";
10
11
  import { createFetchRunner, type HttpRunner } from "../http.ts";
11
12
  import { resolveSearchApiKey, type WebSearchConfig } from "./config.ts";
12
13
  import { resolveProvider } from "./registry.ts";
@@ -75,10 +76,6 @@ export function createThrottle(now: () => number = Date.now, sleep: ThrottleSlee
75
76
  };
76
77
  }
77
78
 
78
- function describe(error: unknown): string {
79
- return error instanceof Error ? error.message : String(error);
80
- }
81
-
82
79
  /** A model-readable reason a forced provider cannot run. */
83
80
  function notConfigured(id: SearchProviderId): string {
84
81
  if (id === "searxng") {
@@ -119,7 +116,7 @@ export async function runSearch(
119
116
  });
120
117
  } catch (error) {
121
118
  if (error instanceof WebSearchError) throw error;
122
- throw new WebSearchError(`Search failed: ${describe(error)}`);
119
+ throw new WebSearchError(`Search failed: ${messageOf(error)}`);
123
120
  }
124
121
 
125
122
  if (!result.answer && result.results.length === 0) {
@@ -116,6 +116,8 @@ polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
116
116
  actions and the only route outside the TUI; an unknown subcommand reports the
117
117
  valid ones.
118
118
 
119
+ <img src="../../assets/wechat.png" alt="The /wechat control screen: account and an open connection, peer, queued, refused, and waiting-prompt rows, three conversation rows showing running, queued, and idle states, and the key hints" width="100%">
120
+
119
121
  Pause is per runtime and keeps the account and peer sessions: a running turn
120
122
  may finish and reply, a waiting prompt is withdrawn with the timeout notice,
121
123
  and the poller lock is released so another runtime can take over. Resume
@@ -514,7 +516,7 @@ status, which is what the old start/stop guidance used to do.
514
516
  | `lock.ts` | Poller lockfile. |
515
517
  | `format.ts` | Sanitize, chunk, and label helpers (text only). |
516
518
  | `notices.ts` | The peer-facing notice catalog: one line per kind, in the peer's language. |
517
- | `errors.ts` | `messageOf`, the one error-to-line helper every notice and tool error uses. |
519
+ | `errors.ts` | Re-exports `messageOf` from `lib/errors.ts`, the one error-to-line helper every notice and tool error uses. |
518
520
  | `timer.ts` | The timer seam: the cancellable one-shot `ArmTimer`, `defaultTimer`, and the abortable `defaultSleep`. |
519
521
  | `interaction.ts` | The WeChat `InteractionChannel`: pending-prompt state machine and reply routing. |
520
522
  | `prompt.ts` | Pure prompt formatting and reply parsing. |