@gavin-hu/my-pi-agent 0.8.1 → 0.10.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 (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -3,48 +3,53 @@
3
3
  *
4
4
  * `WatchListComponent` is the dismissible `/watch` screen with a focused detail
5
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. Every
7
- * line is clipped with `truncateToWidth`; matched lines arrive already sanitized
8
- * by the runtime, so no untrusted escape sequence reaches the terminal here.
6
+ * tracks the selection by id so a live re-sort cannot move the cursor, through
7
+ * the shared `IdCursor` and `LinePane`.
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
12
  */
10
13
 
11
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
- import {
13
- Key,
14
- matchesKey,
15
- truncateToWidth,
16
- visibleWidth,
17
- type Component,
18
- type TuiMouseEvent,
19
- type TuiMouseEventResult,
20
- } from "@earendil-works/pi-tui";
21
- import {
22
- clampScroll,
23
- fitRows,
24
- formatRange,
25
- isCloseKey,
26
- keepVisible,
27
- navIntent,
28
- navigateScroll,
29
- selectionMarker,
30
- wheelDelta,
31
- } from "../../lib/list-cursor.ts";
32
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
33
- import { expandHint } from "../../lib/ui.ts";
34
- import { compareWatches, formatWatchDetail, shortLabel, statusGlyph } from "./format.ts";
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";
23
+ import { paint } from "../../lib/tui/tokens.ts";
24
+ import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
35
25
  import type { WatchRecord } from "./types.ts";
36
26
 
37
27
  /** Rows the `/watch` screen body shows when the terminal height is unknown. */
38
28
  const SCREEN_DEFAULT_ROWS = 14;
39
29
 
40
- /** One watcher row: marker, status glyph, id, label, and match count. */
41
- function watchRow(watch: WatchRecord, theme: Theme, width: number, selected = false): string {
42
- const head = `${selectionMarker(theme, selected)}${statusGlyph(watch.status, theme)} ${watch.id} `;
43
- const count = `${watch.matchCount}`;
44
- const available = Math.max(1, width - visibleWidth(head) - count.length - 1);
45
- const clipped = truncateToWidth(shortLabel(watch), available, "…");
46
- const pad = " ".repeat(Math.max(0, available - visibleWidth(clipped)));
47
- return truncateToWidth(`${head}${clipped}${pad} ${theme.fg("dim", count)}`, width);
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
+ /** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
40
+ function watchRow(
41
+ watch: WatchRecord,
42
+ theme: Theme,
43
+ width: number,
44
+ options: Pick<StatusRow, "selected" | "marker"> = {},
45
+ ): string {
46
+ return statusRow(theme, width, {
47
+ ...options,
48
+ glyph: statusGlyphChar(watch.status),
49
+ glyphRole: statusRole(watch.status),
50
+ label: `${watch.id} ${shortLabel(watch)}`,
51
+ trailing: `${watch.matchCount}`,
52
+ });
48
53
  }
49
54
 
50
55
  /** Input for the transcript result: a themed watcher list or a matched-line tail. */
@@ -79,24 +84,18 @@ export class WatchResult implements Component {
79
84
  // A leading blank line separates the result from the call header.
80
85
  const lines: string[] = [""];
81
86
  if (this.input.kind === "list") {
82
- for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w));
87
+ for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w, { marker: false }));
83
88
  if (this.input.more > 0) {
84
- lines.push(
85
- truncateToWidth(` ${this.theme.fg("dim", `… ${this.input.more} more`)} ${expandHint(this.theme)}`, w, "…"),
86
- );
89
+ const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
90
+ lines.push(clip(` ${note}`, w));
87
91
  }
88
92
  } else {
89
93
  if (this.input.earlier > 0) {
90
- lines.push(
91
- truncateToWidth(
92
- `${this.theme.fg("dim", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`,
93
- w,
94
- "…",
95
- ),
96
- );
94
+ const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`;
95
+ lines.push(clip(note, w));
97
96
  }
98
97
  for (const line of this.input.lines) {
99
- lines.push(this.theme.fg("toolOutput", truncateToWidth(line, w, "…")));
98
+ lines.push(this.theme.fg("toolOutput", clip(line, w)));
100
99
  }
101
100
  }
102
101
  return lines;
@@ -116,19 +115,12 @@ type PendingConfirm = { kind: "stop"; id: string; label: string } | { kind: "cle
116
115
  /** Dismissible `/watch` screen: a selectable list with a focus pane and a matches view. */
117
116
  export class WatchListComponent implements Component {
118
117
  private mode: "list" | "lines" = "list";
119
- /** Stable selection identity; survives the live re-sort of the watcher list. */
120
- private selectedId: string | undefined;
121
- private listScroll = 0;
122
- /** Watcher whose matches pane is open; captured so a re-sort cannot switch it. */
123
- private lineWatchId: string | undefined;
124
- private lineScroll = 0;
125
- private lineLines: string[] = [];
126
- private lineTitle = "";
127
- private lineMore = false;
128
- private follow = true;
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();
129
122
  private confirm: PendingConfirm | undefined;
130
123
  private notice: string | undefined;
131
- private visible = SCREEN_DEFAULT_ROWS;
132
124
 
133
125
  constructor(
134
126
  private readonly watches: () => WatchRecord[],
@@ -143,81 +135,43 @@ export class WatchListComponent implements Component {
143
135
 
144
136
  /** Replace the matches pane contents (called by the command's poll loop). */
145
137
  setLines(title: string, lines: string[], meta: { more?: boolean } = {}): void {
146
- const titleChanged = title !== this.lineTitle;
147
- const linesChanged = !this.sameLines(lines);
148
- const moreChanged = meta.more !== undefined && meta.more !== this.lineMore;
149
- this.lineTitle = title;
150
- this.lineLines = lines;
151
- if (meta.more !== undefined) this.lineMore = meta.more;
152
- if (this.follow) this.lineScroll = this.maxLineScroll;
153
- else this.lineScroll = Math.min(this.lineScroll, this.maxLineScroll);
154
- if (titleChanged || linesChanged || moreChanged) this.requestRender();
155
- }
156
-
157
- private sameLines(next: string[]): boolean {
158
- if (next.length !== this.lineLines.length) return false;
159
- for (let i = 0; i < next.length; i++) if (next[i] !== this.lineLines[i]) return false;
160
- return true;
138
+ if (this.pane.update({ title, lines, more: meta.more })) this.requestRender();
161
139
  }
162
140
 
163
141
  /** The watcher whose matches pane is open, or undefined in list mode. */
164
142
  currentLineId(): string | undefined {
165
- return this.mode === "lines" ? this.lineWatchId : undefined;
143
+ return this.mode === "lines" && this.pane.id ? this.pane.id : undefined;
166
144
  }
167
145
 
168
146
  /** Re-read the open matches pane from the runtime. */
169
147
  refreshLines(id: string): void {
170
148
  const result = this.callbacks.lines(id);
171
- if (result) this.setLines(this.lineTitle, result.lines, { more: result.more });
149
+ if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
172
150
  }
173
151
 
174
152
  private get ordered(): WatchRecord[] {
175
153
  return [...this.watches()].sort(compareWatches);
176
154
  }
177
155
 
178
- private indexOfSelected(ordered: WatchRecord[]): number {
179
- if (ordered.length === 0) return 0;
180
- const index = this.selectedId ? ordered.findIndex((watch) => watch.id === this.selectedId) : -1;
181
- return index >= 0 ? index : 0;
182
- }
183
-
184
- private setSelection(next: number): void {
156
+ /** Focus watcher `next` (clamped) and keep it visible. */
157
+ private select(next: number): void {
185
158
  const ordered = this.ordered;
186
159
  if (ordered.length === 0) return;
187
- const index = Math.min(Math.max(0, next), ordered.length - 1);
188
- this.selectedId = ordered[index].id;
189
- this.listScroll = Math.max(0, keepVisible(this.listScroll, index, this.visible));
160
+ this.cursor.select(ordered, watchId, next);
190
161
  this.requestRender();
191
162
  }
192
163
 
193
- private clampSelection(): void {
194
- const ordered = this.ordered;
195
- if (ordered.length === 0) {
196
- this.selectedId = undefined;
197
- this.listScroll = 0;
198
- return;
199
- }
200
- this.selectedId = ordered[this.indexOfSelected(ordered)].id;
201
- this.listScroll = clampScroll(this.listScroll, ordered.length, this.visible);
202
- }
203
-
204
- private get maxLineScroll(): number {
205
- return Math.max(0, this.lineLines.length - this.visible);
206
- }
207
-
208
164
  private openLines(): void {
209
- const watch = this.ordered[this.indexOfSelected(this.ordered)];
165
+ const watch = this.ordered[this.cursor.indexOf(this.ordered, watchId)];
210
166
  if (!watch) return;
211
167
  this.mode = "lines";
212
- this.lineWatchId = watch.id;
213
- this.lineScroll = 0;
214
- this.follow = true;
215
- this.lineLines = [];
216
- this.lineTitle = `${watch.id} ${shortLabel(watch)}`;
217
- this.lineMore = false;
218
168
  const result = this.callbacks.lines(watch.id);
219
- if (result) this.setLines(this.lineTitle, result.lines, { more: result.more });
220
- else this.requestRender();
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();
221
175
  }
222
176
 
223
177
  handleInput(data: string): void {
@@ -233,8 +187,12 @@ export class WatchListComponent implements Component {
233
187
  const delta = wheelDelta(event);
234
188
  if (delta === undefined) return undefined;
235
189
  if (this.confirm) return { handled: true };
236
- if (this.mode === "list") this.setSelection(this.indexOfSelected(this.ordered) + delta);
237
- else this.setLineScroll(this.lineScroll + delta);
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
+ }
238
196
  return { handled: true };
239
197
  }
240
198
 
@@ -248,25 +206,25 @@ export class WatchListComponent implements Component {
248
206
  this.requestRender();
249
207
  }
250
208
  const ordered = this.ordered;
251
- const index = this.indexOfSelected(ordered);
209
+ const index = this.cursor.indexOf(ordered, watchId);
252
210
  switch (navIntent(data)) {
253
211
  case "up":
254
- this.setSelection(index - 1);
212
+ this.select(index - 1);
255
213
  return;
256
214
  case "down":
257
- this.setSelection(index + 1);
215
+ this.select(index + 1);
258
216
  return;
259
217
  case "pageUp":
260
- this.setSelection(index - this.visible);
218
+ this.select(index - this.cursor.visible);
261
219
  return;
262
220
  case "pageDown":
263
- this.setSelection(index + this.visible);
221
+ this.select(index + this.cursor.visible);
264
222
  return;
265
223
  case "home":
266
- this.setSelection(0);
224
+ this.select(0);
267
225
  return;
268
226
  case "end":
269
- this.setSelection(ordered.length - 1);
227
+ this.select(ordered.length - 1);
270
228
  return;
271
229
  }
272
230
  if (matchesKey(data, Key.enter) || data === "l") {
@@ -295,7 +253,7 @@ export class WatchListComponent implements Component {
295
253
  this.confirm = undefined;
296
254
  if (pending.kind === "stop") this.callbacks.stop(pending.id);
297
255
  else this.callbacks.clear();
298
- this.clampSelection();
256
+ this.cursor.clamp(this.ordered, watchId);
299
257
  this.requestRender();
300
258
  return;
301
259
  }
@@ -312,36 +270,24 @@ export class WatchListComponent implements Component {
312
270
  return `Clear ${pending.count} stopped watcher${pending.count === 1 ? "" : "s"}? y/N`;
313
271
  }
314
272
 
315
- private setLineScroll(next: number): void {
316
- this.lineScroll = clampScroll(next, this.lineLines.length, this.visible);
317
- this.follow = this.lineScroll >= this.maxLineScroll;
318
- this.requestRender();
319
- }
320
-
321
273
  private handleLinesInput(data: string): void {
322
274
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
323
275
  this.mode = "list";
324
- this.lineWatchId = undefined;
276
+ this.pane.close();
325
277
  this.requestRender();
326
278
  return;
327
279
  }
328
- const next = navigateScroll(data, this.lineScroll, this.lineLines.length, this.visible);
329
- if (next !== undefined) {
330
- this.setLineScroll(next);
331
- return;
332
- }
333
- if (data === "g") this.setLineScroll(0);
334
- else if (data === "G") this.setLineScroll(this.maxLineScroll);
280
+ if (this.pane.handleKey(data)) this.requestRender();
335
281
  }
336
282
 
337
283
  private summary(watches: WatchRecord[], width: number): string {
338
- const sep = this.theme.fg("muted", " · ");
339
- let text = this.theme.fg("muted", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`);
284
+ const cells = [paint(this.theme, "meta", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`)];
340
285
  const running = watches.filter((watch) => watch.status === "running").length;
341
286
  const matched = watches.reduce((sum, watch) => sum + watch.matchCount, 0);
342
- if (running > 0) text += sep + this.theme.fg("muted", `${running} running`);
343
- if (matched > 0) text += sep + this.theme.fg("accent", `${matched} matches`);
344
- return truncateToWidth(` ${text}`, width);
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);
345
291
  }
346
292
 
347
293
  render(width: number): string[] {
@@ -351,77 +297,77 @@ export class WatchListComponent implements Component {
351
297
 
352
298
  private renderList(w: number): string[] {
353
299
  const watches = this.ordered;
354
- this.clampSelection();
355
- const index = this.indexOfSelected(watches);
300
+ this.cursor.clamp(watches, watchId);
301
+ const index = this.cursor.indexOf(watches, watchId);
356
302
  const focused = watches[index];
357
303
  const detail = focused ? formatWatchDetail(focused, Date.now()) : [];
358
- // header, summary, blank, blank-after-list, blank-after-detail, hint, blank
359
- const chrome = 7 + detail.length;
360
- this.visible = fitRows(this.viewportRowsSource, watches.length, chrome, SCREEN_DEFAULT_ROWS);
361
- this.clampSelection();
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);
362
311
 
363
- const lines: string[] = [screenHeader(this.theme, w, "Watch"), this.summary(watches, w), ""];
312
+ const body: string[] = [];
313
+ let range: string | undefined;
364
314
  if (watches.length === 0) {
365
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No watchers.")}`, w));
315
+ body.push(...emptyState(this.theme, w, { label: "No watchers." }));
366
316
  } else {
367
- const end = Math.min(watches.length, this.listScroll + this.visible);
368
- for (let i = this.listScroll; i < end; i++) {
369
- lines.push(watchRow(watches[i], this.theme, w, i === index));
370
- }
371
- if (this.listScroll > 0 || end < watches.length) {
372
- lines.push(formatRange(this.theme, w, { start: this.listScroll, end, total: watches.length }));
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 });
373
322
  }
374
323
  }
375
- lines.push("");
376
- for (const line of detail) lines.push(truncateToWidth(` ${this.theme.fg("muted", line)}`, w));
377
- lines.push("");
378
- lines.push(
379
- this.confirm
380
- ? truncateToWidth(` ${this.theme.fg("warning", this.confirmPrompt())}`, w)
381
- : this.notice
382
- ? truncateToWidth(` ${this.theme.fg("muted", this.notice)}`, w)
383
- : screenHint(this.theme, w, [
384
- "Esc close",
385
- "↑/↓ or j/k select",
386
- "Enter or l matches",
387
- "x or K stop",
388
- "d clear stopped",
389
- "PgUp/PgDn",
390
- ]),
391
- );
392
- lines.push("");
393
- return lines;
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
+ });
394
347
  }
395
348
 
396
349
  private renderLines(w: number): string[] {
397
- // header, title, blank, blank-after-lines, hint, blank
398
- const chrome = 6;
399
- this.visible = fitRows(this.viewportRowsSource, this.lineLines.length, chrome, SCREEN_DEFAULT_ROWS);
400
- this.lineScroll = clampScroll(this.lineScroll, this.lineLines.length, this.visible);
401
- if (this.follow) this.lineScroll = this.maxLineScroll;
402
-
403
- const label = this.lineTitle ? `Watch Matches · ${this.lineTitle}` : "Watch Matches";
404
- const parts: string[] = [];
405
- if (this.lineMore && this.lineLines.length > 0) parts.push(`showing last ${this.lineLines.length} lines`);
406
- if (this.lineLines.length > 0) parts.push(this.follow ? "following" : "paused");
407
- const titleLine = truncateToWidth(` ${this.theme.fg("dim", parts.join(" · "))}`, w);
408
- const lines: string[] = [screenHeader(this.theme, w, label), titleLine, ""];
409
- if (this.lineLines.length === 0) {
410
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No matches yet.")}`, w));
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." }));
411
357
  } else {
412
- const end = Math.min(this.lineLines.length, this.lineScroll + this.visible);
413
- for (let i = this.lineScroll; i < end; i++) {
414
- lines.push(truncateToWidth(` ${this.lineLines[i]}`, w));
415
- }
416
- if (this.lineScroll > 0 || end < this.lineLines.length) {
417
- lines.push(
418
- formatRange(this.theme, w, { start: this.lineScroll, end, total: this.lineLines.length, label: "line" }),
419
- );
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" });
420
362
  }
421
363
  }
422
- lines.push("");
423
- lines.push(screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]));
424
- lines.push("");
425
- return lines;
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
+ });
426
372
  }
427
373
  }
@@ -3,26 +3,19 @@
3
3
  * alert.
4
4
  *
5
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". It holds only
7
- * the last context it was attached to; every method no-ops if nothing is
8
- * attached or the UI is unavailable in the current mode.
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.
9
9
  */
10
10
 
11
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
12
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
13
+ import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
13
14
  import type { WatchConfig } from "./config.ts";
14
15
  import { shortLabel } from "./format.ts";
15
16
  import type { WatchRecord } from "./types.ts";
16
17
 
17
- export interface UiController {
18
- /** Remember the live context; later calls use it when no context is passed. */
19
- attach(ctx: ExtensionContext): void;
20
- /** Forget the context, for example on shutdown or reload. */
21
- detach(): void;
22
- /** Repaint the chips using the attached context. */
23
- paint(): void;
24
- /** Publish the footer chips (and attach `ctx` when given). */
25
- setStatus(ctx?: ExtensionContext): void;
18
+ export interface UiController extends StatusController {
26
19
  /** Alert the user to a new match; called once per unreported burst. */
27
20
  notifyMatch(watch: WatchRecord, line: string): void;
28
21
  }
@@ -35,41 +28,21 @@ export interface UiOptions {
35
28
 
36
29
  export function createUiController(options: UiOptions): UiController {
37
30
  const { getWatches, getUnseenMatches, getConfig } = options;
31
+ // The match alert also needs the context, so it is mirrored here.
38
32
  let ctx: ExtensionContext | undefined;
39
33
 
40
- const theme = (target: ExtensionContext, color: "accent" | "error", text: string): string => {
41
- try {
42
- return target.ui.theme.fg(color, text);
43
- } catch {
44
- return text;
45
- }
46
- };
47
-
48
- const setStatus = (next?: ExtensionContext): void => {
49
- if (next) ctx = next;
50
- const target = ctx;
51
- if (!target) return;
52
- try {
53
- if (!getConfig().showStatus) {
54
- target.ui.setStatus(STATUS_KEYS.watch, undefined);
55
- target.ui.setStatus(STATUS_KEYS.watchMatches, undefined);
56
- return;
57
- }
34
+ const controller = createStatusController({
35
+ keys: [STATUS_KEYS.watch, STATUS_KEYS.watchMatches],
36
+ enabled: () => getConfig().showStatus,
37
+ chips: (): StatusChip[] => {
58
38
  let running = 0;
59
39
  for (const watch of getWatches()) if (watch.status === "running") running++;
60
- const unseen = getUnseenMatches();
61
- target.ui.setStatus(
62
- STATUS_KEYS.watch,
63
- running > 0 ? theme(target, "accent", `${GLYPHS.target} ${running}`) : undefined,
64
- );
65
- target.ui.setStatus(
66
- STATUS_KEYS.watchMatches,
67
- unseen > 0 ? theme(target, "error", `${GLYPHS.warn} ${unseen}`) : undefined,
68
- );
69
- } catch {
70
- // UI may be unavailable in non-interactive modes.
71
- }
72
- };
40
+ return [
41
+ { key: STATUS_KEYS.watch, glyph: GLYPHS.target, count: running, role: "accent" },
42
+ { key: STATUS_KEYS.watchMatches, glyph: GLYPHS.warn, count: getUnseenMatches(), role: "bad" },
43
+ ];
44
+ },
45
+ });
73
46
 
74
47
  const notifyMatch = (watch: WatchRecord, line: string): void => {
75
48
  const target = ctx;
@@ -84,14 +57,19 @@ export function createUiController(options: UiOptions): UiController {
84
57
  return {
85
58
  attach: (next) => {
86
59
  ctx = next;
60
+ controller.attach(next);
87
61
  },
88
62
  detach: () => {
89
63
  ctx = undefined;
64
+ controller.detach();
90
65
  },
91
66
  paint: () => {
92
- setStatus();
67
+ controller.paint();
68
+ },
69
+ setStatus: (next) => {
70
+ if (next) ctx = next;
71
+ controller.setStatus(next);
93
72
  },
94
- setStatus,
95
73
  notifyMatch,
96
74
  };
97
75
  }
@@ -12,6 +12,14 @@ pi -e . # load the whole @gavin-hu/my-pi-agent
12
12
  pi install ./ # or install the package
13
13
  ```
14
14
 
15
+ ## Tool rendering
16
+
17
+ How the `web_search` and `web_fetch` calls and their results look in the transcript — the call line as a headline and the body indented under it:
18
+
19
+ <img src="../../assets/tools/web_search.png" alt="The web_search tool's call and result: the call line and an indented body with the query and the ranked results" width="100%">
20
+
21
+ <img src="../../assets/tools/web_fetch.png" alt="The web_fetch tool's call and result: the call line and an indented body with the URL, title, and fetched text preview" width="100%">
22
+
15
23
  ## What it does
16
24
 
17
25
  - Registers `web_search`, general web search through a pluggable provider:
@@ -46,17 +54,18 @@ web_fetch({ url: "https://pi.dev/", find: ["documentation", "extensions"] })
46
54
 
47
55
  ### Transcript
48
56
 
49
- The call line names the query or URL. The result separates the header from its
50
- body and sanitizes untrusted web text (answers, titles, fetched titles and
51
- errors) to one line before it reaches the terminal. A search result labels its
52
- answer and lists one URL per result; expanding reveals snippets and the full
53
- result list.
57
+ The call line names the query or URL and states the requested limit
58
+ (`(max 8)`), so it never reads as the number of results returned. The result
59
+ separates the header from its body and sanitizes untrusted web text (answers,
60
+ titles, fetched titles and errors) to one line before it reaches the terminal.
61
+ A search result labels its answer and lists one URL per result; expanding
62
+ reveals snippets and the full result list.
54
63
 
55
64
  ```
56
- web_search "pi coding agent" (8 results)
65
+ web_search "pi coding agent" (max 8)
57
66
 
58
67
  via searxng · 8 results
59
- answer Pi is a coding agent harness …
68
+ Answer: Pi is a coding agent harness …
60
69
  1. Pi — a coding agent harness
61
70
  https://pi.dev/
62
71
  2. GitHub - earendil-works/pi