@gavin-hu/my-pi-agent 0.21.0 → 0.22.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -16,7 +16,7 @@ import { lastTodos } from "../../lib/todo-state.ts";
16
16
  import { DEFAULT_TODO_CONFIG, type TodoConfig } from "./config.ts";
17
17
  import { shouldNudge } from "./nudge.ts";
18
18
  import { hasOpenTodos } from "./state.ts";
19
- import { TodoWidget, WIDGET_KEY } from "./tui.ts";
19
+ import { TodoWidget, WIDGET_KEY } from "./widget.ts";
20
20
  import type { Todo } from "./types.ts";
21
21
 
22
22
  export interface TodoRuntime {
@@ -22,7 +22,7 @@ import { paint } from "../../lib/tui/tokens.ts";
22
22
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
23
23
  import type { TodoRuntime } from "./runtime.ts";
24
24
  import { TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
25
- import { TodoResult as TodoResultView } from "./tui.ts";
25
+ import { TodoResult as TodoResultView } from "./render.ts";
26
26
  import type { Todo, TodoDetails } from "./types.ts";
27
27
 
28
28
  export const TOOL_NAME = TODO_TOOL;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The `/todos` screen: a scrollable, dismissible list with its own frame.
3
+ *
4
+ * The frame, the hints, the range line and the empty state come from the
5
+ * `lib/tui/` kit (`dockListFrame`, `screenHint`, `formatRange`, `emptyState`),
6
+ * and the rows from `todoRow` in `format.ts`.
7
+ *
8
+ * Full-screen surfaces live under `view/` and load on first use: the `/todos`
9
+ * command imports this module behind its TUI branch, so registration never pays
10
+ * for it. The widget and the transcript block, which Pi draws synchronously,
11
+ * stay outside this directory on purpose.
12
+ */
13
+
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import { 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 } from "../../../lib/tui/columns.ts";
18
+ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDelta } from "../../../lib/tui/cursor.ts";
19
+ import { emptyState } from "../../../lib/tui/rows.ts";
20
+ import { titleRule } from "../../../lib/tui/rules.ts";
21
+ import { paint } from "../../../lib/tui/tokens.ts";
22
+ import { progressSummary, todoRow } from "../format.ts";
23
+ import type { Todo } from "../types.ts";
24
+
25
+ /** Items the `/todos` screen shows when the terminal height is unknown. */
26
+ const SCREEN_DEFAULT_ITEMS = 12;
27
+
28
+ /** Dismissible, scrollable list opened by `/todos`; reads the live list on each render. */
29
+ export class TodoListComponent implements Component {
30
+ private scrollTop = 0;
31
+ /** Rows shown at once; recomputed from the terminal height on every render. */
32
+ private visible = SCREEN_DEFAULT_ITEMS;
33
+
34
+ constructor(
35
+ private readonly getTodos: () => Todo[],
36
+ private readonly theme: Theme,
37
+ private readonly onClose: () => void,
38
+ private readonly requestRender: () => void,
39
+ private readonly viewportRowsSource?: ViewportRowsSource,
40
+ ) {}
41
+
42
+ /** The live list; re-read on each render so an open screen follows changes. */
43
+ private get todos(): Todo[] {
44
+ return this.getTodos();
45
+ }
46
+
47
+ private get maxScroll(): number {
48
+ return Math.max(0, this.todos.length - this.visible);
49
+ }
50
+
51
+ private setScroll(next: number): void {
52
+ const clamped = clampScroll(next, this.todos.length, this.visible);
53
+ if (clamped === this.scrollTop) return;
54
+ this.scrollTop = clamped;
55
+ this.requestRender();
56
+ }
57
+
58
+ handleInput(data: string): void {
59
+ if (isCloseKey(data)) {
60
+ this.onClose();
61
+ return;
62
+ }
63
+ // `g`/`G` jump to the ends, matching the `/jobs` and plan screens.
64
+ if (data === "g") {
65
+ this.setScroll(0);
66
+ return;
67
+ }
68
+ if (data === "G") {
69
+ this.setScroll(this.maxScroll);
70
+ return;
71
+ }
72
+ const next = navigateScroll(data, this.scrollTop, this.todos.length, this.visible);
73
+ if (next !== undefined) this.setScroll(next);
74
+ }
75
+
76
+ /** Wheel scrolling moves the list, matching the other list screens. */
77
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
78
+ const delta = wheelDelta(event);
79
+ if (delta === undefined) return undefined;
80
+ this.setScroll(this.scrollTop + delta);
81
+ return { handled: true };
82
+ }
83
+
84
+ invalidate(): void {}
85
+
86
+ render(width: number): string[] {
87
+ const w = Math.max(1, width);
88
+ const header = titleRule(this.theme, w, "Todos");
89
+
90
+ if (this.todos.length === 0) {
91
+ return dockListFrame({
92
+ theme: this.theme,
93
+ width: w,
94
+ header,
95
+ body: emptyState(this.theme, w, { label: "No todos yet. Ask the agent to plan some work." }),
96
+ hint: screenHint(this.theme, w, ["Esc close"]),
97
+ });
98
+ }
99
+
100
+ // header, summary, blank, blank-after-list, hint, closing rule
101
+ const chrome = 6;
102
+ this.visible = fitRows(this.viewportRowsSource, this.todos.length, chrome, SCREEN_DEFAULT_ITEMS);
103
+ this.scrollTop = clampScroll(this.scrollTop, this.todos.length, this.visible);
104
+
105
+ const end = Math.min(this.todos.length, this.scrollTop + this.visible);
106
+ const body: string[] = [];
107
+ for (let i = this.scrollTop; i < end; i++) body.push(todoRow(this.todos[i], this.theme, w));
108
+
109
+ return dockListFrame({
110
+ theme: this.theme,
111
+ width: w,
112
+ header,
113
+ summary: clip(` ${paint(this.theme, "meta", progressSummary(this.todos))}`, w),
114
+ body,
115
+ range:
116
+ this.scrollTop > 0 || end < this.todos.length
117
+ ? formatRange(this.theme, w, { start: this.scrollTop, end, total: this.todos.length })
118
+ : undefined,
119
+ hint: screenHint(this.theme, w, ["Esc close", "↑/↓ or k/j scroll", "PgUp/PgDn", "g/G or Home/End"]),
120
+ });
121
+ }
122
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The persistent one-line todo widget, shown above the editor while the list
3
+ * has work.
4
+ *
5
+ * It is a rail, not a screen: `runtime.syncWidget` sets it whenever the list
6
+ * changes, through a synchronous factory Pi calls at paint time, so this module
7
+ * stays on the registration path. `WIDGET_KEY` lives here for the same reason —
8
+ * the runtime and the tests read it without touching a component.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
14
+ import { paint } from "../../lib/tui/tokens.ts";
15
+ import { progressCount, todoLabel } from "./format.ts";
16
+ import { currentTodo } from "./state.ts";
17
+ import type { Todo } from "./types.ts";
18
+
19
+ /** Widget key used with `ctx.ui.setWidget()`. */
20
+ export const WIDGET_KEY = "todo-widget";
21
+
22
+ /**
23
+ * One-line widget summary: progress plus the current item, or a completion note
24
+ * when nothing is open. Label-first and glyph-free, matching the `goal` and
25
+ * `jobs` widgets. Callers clip it to the available width.
26
+ */
27
+ function todoLine(todos: Todo[], theme: Theme): string {
28
+ const head = `${paint(theme, "accent", "Todos")}${paint(theme, "faint", SEPARATORS.item)}${paint(theme, "faint", progressCount(todos))}`;
29
+ const current = currentTodo(todos);
30
+ if (!current) return `${head} ${paint(theme, "faint", "completed")}`;
31
+ return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
32
+ }
33
+
34
+ /** Persistent one-line widget shown above the editor while the list has work. */
35
+ export class TodoWidget implements Component {
36
+ constructor(
37
+ private readonly todos: Todo[],
38
+ private readonly theme: Theme,
39
+ ) {}
40
+
41
+ invalidate(): void {}
42
+
43
+ render(width: number): string[] {
44
+ if (this.todos.length === 0) return [];
45
+ return [truncateToWidth(todoLine(this.todos, this.theme), Math.max(1, width), "…")];
46
+ }
47
+ }
@@ -8,10 +8,8 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { withPolledScreen } from "../../lib/screen-session.ts";
12
11
  import { formatWatchList, shortLabel } from "./format.ts";
13
12
  import type { WatchRuntime } from "./runtime.ts";
14
- import { WatchListComponent } from "./tui.ts";
15
13
 
16
14
  /** How often the open screen re-reads live state. */
17
15
  const REFRESH_MS = 500;
@@ -92,6 +90,10 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
92
90
  return;
93
91
  }
94
92
 
93
+ // The dock screen and its poll loop are the TUI's: the listing above and
94
+ // registration load neither.
95
+ const { withPolledScreen } = await import("../../lib/screen-session.ts");
96
+ const { WatchListComponent } = await import("./view/watch-screen.ts");
95
97
  await withPolledScreen(
96
98
  pi,
97
99
  ctx,
@@ -14,7 +14,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
14
14
  import { boundedTail, sanitizeLine } from "../../lib/format.ts";
15
15
  import { clip, clipLabel, labelLine } from "../../lib/tui/columns.ts";
16
16
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
17
- import { byRunningFirst } from "../../lib/tui/rows.ts";
17
+ import { byRunningFirst, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
18
18
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
19
19
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
20
20
 
@@ -23,12 +23,39 @@ export function compileRegex(pattern: string, caseSensitive: boolean): RegExp {
23
23
  return new RegExp(pattern, caseSensitive ? "" : "i");
24
24
  }
25
25
 
26
+ /**
27
+ * A watcher's pattern as `/<pattern>/<flags>`, with the wake badge appended:
28
+ * `/pat/`, `/pat/i`, `/pat/ · wake`, `/pat/i · wake`.
29
+ */
30
+ export function formatPattern(watch: Pick<WatchRecord, "pattern" | "caseSensitive" | "wake">): string {
31
+ const flags = watch.caseSensitive ? "" : "i";
32
+ const badge = watch.wake ? `${SEPARATORS.item}wake` : "";
33
+ return `/${sanitizeLine(watch.pattern)}/${flags}${badge}`;
34
+ }
35
+
26
36
  /** What a watcher reads: a path or a command, clipped for display. */
27
37
  export function sourceLabel(watch: WatchRecord): string {
28
38
  if (watch.source.kind === "file") return clipLabel(watch.source.path ?? "");
29
39
  return clipLabel(`$ ${watch.source.command ?? ""}`);
30
40
  }
31
41
 
42
+ /** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
43
+ export function watchRow(
44
+ watch: WatchRecord,
45
+ theme: Theme,
46
+ width: number,
47
+ options: Pick<StatusRow, "selected" | "marker"> = {},
48
+ ): string {
49
+ return statusRow(theme, width, {
50
+ ...options,
51
+ glyph: statusGlyphChar(watch.status),
52
+ glyphRole: statusRole(watch.status),
53
+ label: `${watch.id} ${shortLabel(watch)}`,
54
+ labelRole: options.selected ? "accent" : "label",
55
+ trailing: `${watch.matchCount}`,
56
+ });
57
+ }
58
+
32
59
  /** A watcher's label, falling back to its source. */
33
60
  export function shortLabel(watch: WatchRecord): string {
34
61
  return clipLabel(watch.label || sourceLabel(watch));
@@ -62,11 +89,14 @@ export function statusGlyphChar(status: WatchStatus): string {
62
89
  }
63
90
  }
64
91
 
65
- /** Short status word, including an exit code or error when present. */
92
+ /** Short status word, including an exit code, the signal that ended it, or an error when present. */
66
93
  export function statusWord(watch: WatchRecord): string {
67
94
  if (watch.status === "error" && watch.error) return `error (${watch.error})`;
68
- if (watch.status === "exited" && watch.exitCode !== undefined && watch.exitCode !== null) {
69
- return `exited (${watch.exitCode})`;
95
+ if (watch.status === "exited") {
96
+ if (typeof watch.exitCode === "number") return `exited (${watch.exitCode})`;
97
+ // Node reports a signalled child with a null code and the signal that ended it, so the
98
+ // code alone would leave a killed command looking like a clean exit.
99
+ if (watch.signal) return `exited (${watch.signal})`;
70
100
  }
71
101
  return watch.status;
72
102
  }
@@ -96,7 +126,7 @@ export function formatWatchStatus(watch: WatchRecord, now = Date.now()): string
96
126
  const lines = [
97
127
  `${watch.id}: ${statusWord(watch)}`,
98
128
  `source: ${sourceLabel(watch)}`,
99
- `pattern: /${sanitizeLine(watch.pattern)}/${watch.caseSensitive ? "" : "i"}`,
129
+ `pattern: ${formatPattern(watch)}`,
100
130
  ];
101
131
  if (watch.exclude.length > 0)
102
132
  lines.push(`exclude: ${watch.exclude.map((item) => `/${sanitizeLine(item)}/`).join(" ")}`);
@@ -174,9 +204,8 @@ export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = D
174
204
  /** Unstyled, sanitized detail lines for the `/watch` focus pane. */
175
205
  export function formatWatchDetail(watch: WatchRecord, now = Date.now()): string[] {
176
206
  const age = Math.max(0, (watch.endedAt ?? now) - watch.startedAt);
177
- const flags = `${watch.caseSensitive ? "" : "i"}${watch.wake ? " · wake" : ""}`;
178
207
  return [
179
- sanitizeLine(`${sourceLabel(watch)} · /${watch.pattern}/${flags}`),
208
+ sanitizeLine(`${sourceLabel(watch)} · ${formatPattern(watch)}`),
180
209
  sanitizeLine(
181
210
  `${statusWord(watch)} · ${watch.linesRead} lines · ${watch.matchCount} matches · ${Math.round(age / 1000)}s`,
182
211
  ),
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `WatchResult` transcript block: the `watch` tool's list-or-matches result.
3
+ *
4
+ * It wraps the shared `TailResult` kit over the `/watch`-style row rail, so the
5
+ * tool's `renderResult` stays a few lines and every row is drawn by `watchRow`
6
+ * in `format.ts`. The `/watch` screen draws the same rows through that helper;
7
+ * this module is on the registration path, and the screen is not.
8
+ */
9
+
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { Component } from "@earendil-works/pi-tui";
12
+ import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
13
+ import { watchRow } from "./format.ts";
14
+ import type { WatchRecord } from "./types.ts";
15
+
16
+ /** Input for the transcript result: a themed watcher list or a matched-line tail. */
17
+ export type WatchResultInput =
18
+ | { kind: "list"; watches: WatchRecord[]; more: number }
19
+ | { kind: "lines"; label: string; lines: string[]; earlier: number };
20
+
21
+ /** Map the watch vocabulary onto the shared transcript tail block. */
22
+ function watchTail(input: WatchResultInput): TailResultInput<WatchRecord> {
23
+ if (input.kind === "lines") {
24
+ return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: input.label };
25
+ }
26
+ return {
27
+ kind: "list",
28
+ rows: input.watches,
29
+ more: input.more,
30
+ rowLine: (watch, width, theme) => watchRow(watch, theme, width, { marker: false }),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Transcript result block for the `watch` tool: the shared tail block over the
36
+ * `/watch`-style row rail or the themed matched-line tail. Reused across renders.
37
+ */
38
+ export class WatchResult implements Component {
39
+ private readonly view: TailResult<WatchRecord>;
40
+
41
+ constructor(input: WatchResultInput, theme: Theme) {
42
+ this.view = new TailResult(watchTail(input), theme);
43
+ }
44
+
45
+ /** Update in place so the transcript can reuse this component across renders. */
46
+ setInput(input: WatchResultInput, theme: Theme): void {
47
+ this.view.setInput(watchTail(input), theme);
48
+ }
49
+
50
+ invalidate(): void {
51
+ this.view.invalidate();
52
+ }
53
+
54
+ render(width: number): string[] {
55
+ return this.view.render(width);
56
+ }
57
+ }
@@ -14,7 +14,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { resolveEffectiveCwd } from "../../lib/env.ts";
15
15
  import { LineSplitter } from "../../lib/lines.ts";
16
16
  import type { KillTreeFn, SpawnFn, SpawnedProcess } from "../../lib/spawn.ts";
17
- import { createChildSupervisor } from "./child.ts";
17
+ import { createChildSupervisor, KILL_GRACE_MS } from "../../lib/child.ts";
18
18
  import { DEFAULT_CONFIG, loadConfig, type WatchConfig } from "./config.ts";
19
19
  import { sanitizeUntrustedLine } from "../../lib/format.ts";
20
20
  import { defaultInterval } from "../../lib/timer.ts";
@@ -24,8 +24,6 @@ import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types
24
24
 
25
25
  /** Largest slice read from a file per tick. */
26
26
  const MAX_CHUNK = 1024 * 1024;
27
- /** Grace before a stopped command escalates to SIGKILL; `child.ts` defaults the same. */
28
- const KILL_GRACE_MS = 2000;
29
27
 
30
28
  /** Internal watcher state: the public record plus non-serializable fields. */
31
29
  interface Internal {
@@ -277,24 +275,29 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
277
275
  };
278
276
 
279
277
  const startCommand = (w: Internal): void => {
280
- const error = children.start(w, w.source.command ?? "", w.source.cwd ?? process.cwd(), {
281
- onData: (chunk) => consume(w, chunk, now()),
282
- onSettled: (outcome) => {
283
- if (w.status !== "running") return;
284
- if (outcome.kind === "closed") {
285
- flushPending(w, now());
286
- w.status = "exited";
287
- w.exitCode = outcome.code;
288
- w.signal = outcome.signal;
289
- } else {
290
- w.status = "error";
291
- w.error = outcome.message;
292
- }
293
- w.endedAt = now();
294
- ui.paint();
295
- if (runningCount() === 0) stopClock();
278
+ const error = children.start(
279
+ w,
280
+ w.source.command ?? "",
281
+ { cwd: w.source.cwd ?? process.cwd() },
282
+ {
283
+ onData: (chunk) => consume(w, chunk, now()),
284
+ onSettled: (outcome) => {
285
+ if (w.status !== "running") return;
286
+ if (outcome.kind === "closed") {
287
+ flushPending(w, now());
288
+ w.status = "exited";
289
+ w.exitCode = outcome.code;
290
+ w.signal = outcome.signal;
291
+ } else {
292
+ w.status = "error";
293
+ w.error = outcome.message;
294
+ }
295
+ w.endedAt = now();
296
+ ui.paint();
297
+ if (runningCount() === 0) stopClock();
298
+ },
296
299
  },
297
- });
300
+ );
298
301
  if (error === undefined) return;
299
302
  w.status = "error";
300
303
  w.error = error;
@@ -313,8 +316,10 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
313
316
 
314
317
  const add = (opts: AddWatchOptions, ctx: ExtensionContext): WatchRecord => {
315
318
  if (!config.enabled) throw new Error("watch is disabled.");
316
- if (watches.size >= config.maxWatchers) {
317
- throw new Error(`at most ${config.maxWatchers} watchers; stop one first.`);
319
+ if (runningCount() >= config.maxWatchers) {
320
+ throw new Error(
321
+ `at most ${config.maxWatchers} live watcher${config.maxWatchers === 1 ? "" : "s"}; stop one first.`,
322
+ );
318
323
  }
319
324
  const base = resolveEffectiveCwd(ctx.cwd);
320
325
  const source: WatchSource = opts.path
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { sanitizeLine } from "../../lib/format.ts";
12
11
  import { errorResult, textResult } from "../../lib/tool-result.ts";
13
12
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
13
  import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
@@ -17,6 +16,7 @@ import { paint } from "../../lib/tui/tokens.ts";
17
16
  import {
18
17
  compareWatches,
19
18
  formatCallText,
19
+ formatPattern,
20
20
  formatTail,
21
21
  formatWatchList,
22
22
  formatWatchOutcomeLine,
@@ -25,7 +25,7 @@ import {
25
25
  } from "./format.ts";
26
26
  import type { WatchRuntime } from "./runtime.ts";
27
27
  import { WatchParams, normalizeCall, type WatchArgs, type WatchCall } from "./schema.ts";
28
- import { WatchResult, type WatchResultInput } from "./tui.ts";
28
+ import { WatchResult, type WatchResultInput } from "./render.ts";
29
29
  import type { WatchDetails } from "./types.ts";
30
30
 
31
31
  export const TOOL_NAME = "watch";
@@ -81,9 +81,9 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
81
81
  },
82
82
  ctx as ExtensionContext,
83
83
  );
84
- const flags = `${call.caseSensitive ? "" : "i"}${watch.wake ? ", wake" : ""}`;
84
+ const pattern = formatPattern(watch);
85
85
  return textResult(
86
- `Watching ${watch.id}: ${sourceLabel(watch)} → /${sanitizeLine(watch.pattern)}/${flags}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
86
+ `Watching ${watch.id}: ${sourceLabel(watch)} → ${pattern}\nRead with \`watch matches ${watch.id}\` or \`watch logs ${watch.id}\`.`,
87
87
  { action: "add", watch } satisfies WatchDetails,
88
88
  );
89
89
  }
@@ -1,87 +1,25 @@
1
1
  /**
2
- * Terminal rendering for the watch extension's `/watch` screen.
2
+ * The `/watch` screen: a dismissible list with a focused detail pane and a
3
+ * matches view, drawn as a `DockListScreen`.
3
4
  *
4
- * `WatchListComponent` is the dismissible `/watch` screen with a focused detail
5
- * pane and a matches view. It reads live state through injected closures and
6
- * tracks the selection by id so a live re-sort cannot move the cursor, through
7
- * the shared `IdCursor` and `LinePane`.
5
+ * It reads live state through injected closures and tracks the selection by id
6
+ * so a live re-sort cannot move the cursor, through the shared `IdCursor` and
7
+ * `LinePane`. The frame, the navigation keys, the y/N confirmation, and the
8
+ * matches pane come from the shared `DockListScreen`, so this module owns only
9
+ * the watch vocabulary: the rows, the focus block, the counts, the hints, and
10
+ * what the verbs do. Matched lines arrive already sanitized by the runtime.
8
11
  *
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
+ * Full-screen surfaces live under `view/` and load on first use: the `/watch`
13
+ * command imports this module behind its TUI branch, so registration never
14
+ * pays for it.
14
15
  */
15
16
 
16
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
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";
22
- import { paint } from "../../lib/tui/tokens.ts";
23
- import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
24
- import type { WatchRecord } from "./types.ts";
25
-
26
- /** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
27
- function watchRow(
28
- watch: WatchRecord,
29
- theme: Theme,
30
- width: number,
31
- options: Pick<StatusRow, "selected" | "marker"> = {},
32
- ): string {
33
- return statusRow(theme, width, {
34
- ...options,
35
- glyph: statusGlyphChar(watch.status),
36
- glyphRole: statusRole(watch.status),
37
- label: `${watch.id} ${shortLabel(watch)}`,
38
- labelRole: options.selected ? "accent" : "label",
39
- trailing: `${watch.matchCount}`,
40
- });
41
- }
42
-
43
- /** Input for the transcript result: a themed watcher list or a matched-line tail. */
44
- export type WatchResultInput =
45
- | { kind: "list"; watches: WatchRecord[]; more: number }
46
- | { kind: "lines"; label: string; lines: string[]; earlier: number };
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
-
61
- /**
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
- */
65
- export class WatchResult implements Component {
66
- private readonly view: TailResult<WatchRecord>;
67
-
68
- constructor(input: WatchResultInput, theme: Theme) {
69
- this.view = new TailResult(watchTail(input), theme);
70
- }
71
-
72
- /** Update in place so the transcript can reuse this component across renders. */
73
- setInput(input: WatchResultInput, theme: Theme): void {
74
- this.view.setInput(watchTail(input), theme);
75
- }
76
-
77
- invalidate(): void {
78
- this.view.invalidate();
79
- }
80
-
81
- render(width: number): string[] {
82
- return this.view.render(width);
83
- }
84
- }
18
+ import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
+ import { DockListScreen, type DetailContents } from "../../../lib/tui/dock-screen.ts";
20
+ import { paint } from "../../../lib/tui/tokens.ts";
21
+ import { compareWatches, formatWatchDetail, shortLabel, watchRow } from "../format.ts";
22
+ import type { WatchRecord } from "../types.ts";
85
23
 
86
24
  export interface WatchListCallbacks {
87
25
  /** Current matched lines for a watcher, or undefined when unknown. */
@@ -157,8 +95,8 @@ export class WatchListComponent extends DockListScreen<WatchRecord> {
157
95
  "Esc close",
158
96
  "↑/↓ or j/k select",
159
97
  { text: "Enter or l matches", color: "accent" },
160
- { text: "x or K stop", color: "warning" },
161
- { text: "d clear stopped", color: "warning" },
98
+ { text: "x or K stop", color: "warn" },
99
+ { text: "d clear stopped", color: "warn" },
162
100
  "PgUp/PgDn",
163
101
  ];
164
102
  }
@@ -28,9 +28,9 @@ import { charLength } from "../text.ts";
28
28
  import { oneLine } from "../transcript.ts";
29
29
  import { createPageCache, type PageCache } from "./cache.ts";
30
30
  import type { WebFetchConfig } from "./config.ts";
31
- import { findPassages, type Passage } from "./find.ts";
31
+ import type { Passage } from "./find.ts";
32
32
  import { formatMatches, formatPage } from "./format.ts";
33
- import { runFetch, type FetchDeps, type PageResult } from "./page.ts";
33
+ import type { FetchDeps, PageResult } from "./page.ts";
34
34
  import { resolveRequest, WebFetchOutput, WebFetchParams, type FetchResponse, type WebFetchArgs } from "./schema.ts";
35
35
  import type { FetchRequest } from "./types.ts";
36
36
  import { loadFetchConfig } from "../config.ts";
@@ -66,6 +66,10 @@ async function loadPage(
66
66
  deps: FetchDeps,
67
67
  cache: PageCache,
68
68
  ): Promise<LoadedPage> {
69
+ // The page loader is the call's, not registration's: the schema and the
70
+ // renderers above never need it, so a listing loads neither it nor its
71
+ // extract/ssrf/PDF subtree.
72
+ const { runFetch } = await import("./page.ts");
69
73
  const cacheable = config.cacheEnabled && request.cacheable && !request.refresh;
70
74
  const cachedEntry = cacheable ? cache.get(url, config.cacheTtlMs) : undefined;
71
75
  if (cachedEntry) return { page: cachedEntry, cached: true, fetchedAt: cachedEntry.fetchedAt };
@@ -83,7 +87,10 @@ async function loadPage(
83
87
  }
84
88
 
85
89
  /** Build the response entry and model-facing text for one fetched page. */
86
- function pageResult(loaded: LoadedPage, request: FetchRequest): { response: FetchResponse; text: string } {
90
+ async function pageResult(
91
+ loaded: LoadedPage,
92
+ request: FetchRequest,
93
+ ): Promise<{ response: FetchResponse; text: string }> {
87
94
  const { page, cached, fetchedAt } = loaded;
88
95
  const finding = request.find.length > 0;
89
96
 
@@ -93,6 +100,7 @@ function pageResult(loaded: LoadedPage, request: FetchRequest): { response: Fetc
93
100
  let matchesTruncated = false;
94
101
 
95
102
  if (finding) {
103
+ const { findPassages } = await import("./find.ts");
96
104
  // Request one extra so a full list proves more hits exist; only the cap is shown.
97
105
  const found = findPassages(page.text, request.find, {
98
106
  mode: request.mode,
@@ -211,7 +219,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
211
219
  let text: string;
212
220
  try {
213
221
  const loaded = await loadPage(request.url, request, config, signal, fetchDeps, cache);
214
- ({ response: page, text } = pageResult(loaded, request));
222
+ ({ response: page, text } = await pageResult(loaded, request));
215
223
  } catch (error) {
216
224
  const message = error instanceof Error ? error.message : String(error);
217
225
  ({ response: page, text } = fetchErrorResult(request.url, request, message));