@gavin-hu/my-pi-agent 0.8.0 → 0.9.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 (98) hide show
  1. package/README.md +9 -8
  2. package/README.zh-CN.md +8 -8
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +62 -14
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -9,7 +9,8 @@
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
- import { reuseText, toolDetails } from "../../lib/tui.ts";
12
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
13
+ import { paint } from "../../lib/tui/tokens.ts";
13
14
  import {
14
15
  compareWatches,
15
16
  formatCallText,
@@ -209,13 +210,13 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
209
210
  return text;
210
211
  }
211
212
  if (details.error) {
212
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
213
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
213
214
  return text;
214
215
  }
215
216
 
216
217
  if (details.action === "list" && details.watches) {
217
218
  if (details.watches.length === 0) {
218
- text.setText(theme.fg("muted", "No watchers."));
219
+ text.setText(paint(theme, "meta", "No watchers."));
219
220
  return text;
220
221
  }
221
222
  const ordered = [...details.watches].sort(compareWatches);
@@ -232,7 +233,7 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
232
233
  const all = raw === "" ? [] : raw.split("\n");
233
234
  const label = details.action === "matches" ? "matches" : "output";
234
235
  if (all.length === 0) {
235
- text.setText(theme.fg("muted", `No ${label} yet.`));
236
+ text.setText(paint(theme, "meta", `No ${label} yet.`));
236
237
  return text;
237
238
  }
238
239
  const shownLines = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
@@ -251,7 +252,11 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
251
252
  if (details.action === "clear") {
252
253
  const count = details.cleared ?? 0;
253
254
  text.setText(
254
- theme.fg("muted", count === 0 ? "Nothing to clear." : `Cleared ${count} watcher${count === 1 ? "" : "s"}.`),
255
+ paint(
256
+ theme,
257
+ "meta",
258
+ count === 0 ? "Nothing to clear." : `Cleared ${count} watcher${count === 1 ? "" : "s"}.`,
259
+ ),
255
260
  );
256
261
  return text;
257
262
  }
@@ -259,12 +264,12 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
259
264
  if (details.watch) {
260
265
  let line = formatWatchOutcomeLine(details.watch, theme);
261
266
  if (details.action === "stop") {
262
- line += theme.fg("warning", details.stopped ? " · stopped" : " · already stopped");
267
+ line += paint(theme, "warn", details.stopped ? " · stopped" : " · already stopped");
263
268
  } else if (details.action === "add") {
264
269
  const badges: string[] = [];
265
270
  if (details.watch.wake) badges.push("wake");
266
271
  if (details.watch.fromStart) badges.push("from start");
267
- if (badges.length > 0) line += theme.fg("dim", ` · ${badges.join(" · ")}`);
272
+ if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
268
273
  }
269
274
  text.setText(line);
270
275
  return text;
@@ -3,48 +3,49 @@
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, listRow } 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. */
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. */
41
40
  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);
41
+ return listRow(theme, width, {
42
+ selected,
43
+ glyph: statusGlyphChar(watch.status),
44
+ glyphRole: statusRole(watch.status),
45
+ label: `${watch.id} ${shortLabel(watch)}`,
46
+ trailing: `${watch.matchCount}`,
47
+ trailingRole: "faint",
48
+ });
48
49
  }
49
50
 
50
51
  /** Input for the transcript result: a themed watcher list or a matched-line tail. */
@@ -81,22 +82,16 @@ export class WatchResult implements Component {
81
82
  if (this.input.kind === "list") {
82
83
  for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w));
83
84
  if (this.input.more > 0) {
84
- lines.push(
85
- truncateToWidth(` ${this.theme.fg("dim", `… ${this.input.more} more`)} ${expandHint(this.theme)}`, w, "…"),
86
- );
85
+ const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
86
+ lines.push(clip(` ${note}`, w));
87
87
  }
88
88
  } else {
89
89
  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
- );
90
+ const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`;
91
+ lines.push(clip(note, w));
97
92
  }
98
93
  for (const line of this.input.lines) {
99
- lines.push(this.theme.fg("toolOutput", truncateToWidth(line, w, "…")));
94
+ lines.push(this.theme.fg("toolOutput", clip(line, w)));
100
95
  }
101
96
  }
102
97
  return lines;
@@ -116,19 +111,12 @@ type PendingConfirm = { kind: "stop"; id: string; label: string } | { kind: "cle
116
111
  /** Dismissible `/watch` screen: a selectable list with a focus pane and a matches view. */
117
112
  export class WatchListComponent implements Component {
118
113
  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;
114
+ /** Focus anchored to a watcher id; the list is re-sorted from live state each render. */
115
+ private readonly cursor = new IdCursor();
116
+ /** The open matches tail. */
117
+ private readonly pane = new LinePane();
129
118
  private confirm: PendingConfirm | undefined;
130
119
  private notice: string | undefined;
131
- private visible = SCREEN_DEFAULT_ROWS;
132
120
 
133
121
  constructor(
134
122
  private readonly watches: () => WatchRecord[],
@@ -143,81 +131,43 @@ export class WatchListComponent implements Component {
143
131
 
144
132
  /** Replace the matches pane contents (called by the command's poll loop). */
145
133
  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;
134
+ if (this.pane.update({ title, lines, more: meta.more })) this.requestRender();
161
135
  }
162
136
 
163
137
  /** The watcher whose matches pane is open, or undefined in list mode. */
164
138
  currentLineId(): string | undefined {
165
- return this.mode === "lines" ? this.lineWatchId : undefined;
139
+ return this.mode === "lines" && this.pane.id ? this.pane.id : undefined;
166
140
  }
167
141
 
168
142
  /** Re-read the open matches pane from the runtime. */
169
143
  refreshLines(id: string): void {
170
144
  const result = this.callbacks.lines(id);
171
- if (result) this.setLines(this.lineTitle, result.lines, { more: result.more });
145
+ if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
172
146
  }
173
147
 
174
148
  private get ordered(): WatchRecord[] {
175
149
  return [...this.watches()].sort(compareWatches);
176
150
  }
177
151
 
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 {
152
+ /** Focus watcher `next` (clamped) and keep it visible. */
153
+ private select(next: number): void {
185
154
  const ordered = this.ordered;
186
155
  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));
156
+ this.cursor.select(ordered, watchId, next);
190
157
  this.requestRender();
191
158
  }
192
159
 
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
160
  private openLines(): void {
209
- const watch = this.ordered[this.indexOfSelected(this.ordered)];
161
+ const watch = this.ordered[this.cursor.indexOf(this.ordered, watchId)];
210
162
  if (!watch) return;
211
163
  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
164
  const result = this.callbacks.lines(watch.id);
219
- if (result) this.setLines(this.lineTitle, result.lines, { more: result.more });
220
- else this.requestRender();
165
+ this.pane.open(watch.id, {
166
+ title: `${watch.id} ${shortLabel(watch)}`,
167
+ lines: result?.lines ?? [],
168
+ more: result?.more ?? false,
169
+ });
170
+ this.requestRender();
221
171
  }
222
172
 
223
173
  handleInput(data: string): void {
@@ -233,8 +183,12 @@ export class WatchListComponent implements Component {
233
183
  const delta = wheelDelta(event);
234
184
  if (delta === undefined) return undefined;
235
185
  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);
186
+ if (this.mode === "list") {
187
+ this.select(this.cursor.indexOf(this.ordered, watchId) + delta);
188
+ } else {
189
+ this.pane.setScroll(this.pane.scroll + delta);
190
+ this.requestRender();
191
+ }
238
192
  return { handled: true };
239
193
  }
240
194
 
@@ -248,25 +202,25 @@ export class WatchListComponent implements Component {
248
202
  this.requestRender();
249
203
  }
250
204
  const ordered = this.ordered;
251
- const index = this.indexOfSelected(ordered);
205
+ const index = this.cursor.indexOf(ordered, watchId);
252
206
  switch (navIntent(data)) {
253
207
  case "up":
254
- this.setSelection(index - 1);
208
+ this.select(index - 1);
255
209
  return;
256
210
  case "down":
257
- this.setSelection(index + 1);
211
+ this.select(index + 1);
258
212
  return;
259
213
  case "pageUp":
260
- this.setSelection(index - this.visible);
214
+ this.select(index - this.cursor.visible);
261
215
  return;
262
216
  case "pageDown":
263
- this.setSelection(index + this.visible);
217
+ this.select(index + this.cursor.visible);
264
218
  return;
265
219
  case "home":
266
- this.setSelection(0);
220
+ this.select(0);
267
221
  return;
268
222
  case "end":
269
- this.setSelection(ordered.length - 1);
223
+ this.select(ordered.length - 1);
270
224
  return;
271
225
  }
272
226
  if (matchesKey(data, Key.enter) || data === "l") {
@@ -295,7 +249,7 @@ export class WatchListComponent implements Component {
295
249
  this.confirm = undefined;
296
250
  if (pending.kind === "stop") this.callbacks.stop(pending.id);
297
251
  else this.callbacks.clear();
298
- this.clampSelection();
252
+ this.cursor.clamp(this.ordered, watchId);
299
253
  this.requestRender();
300
254
  return;
301
255
  }
@@ -312,36 +266,24 @@ export class WatchListComponent implements Component {
312
266
  return `Clear ${pending.count} stopped watcher${pending.count === 1 ? "" : "s"}? y/N`;
313
267
  }
314
268
 
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
269
  private handleLinesInput(data: string): void {
322
270
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
323
271
  this.mode = "list";
324
- this.lineWatchId = undefined;
272
+ this.pane.close();
325
273
  this.requestRender();
326
274
  return;
327
275
  }
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);
276
+ if (this.pane.handleKey(data)) this.requestRender();
335
277
  }
336
278
 
337
279
  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"}`);
280
+ const cells = [paint(this.theme, "meta", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`)];
340
281
  const running = watches.filter((watch) => watch.status === "running").length;
341
282
  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);
283
+ if (running > 0) cells.push(paint(this.theme, "meta", `${running} running`));
284
+ if (matched > 0) cells.push(paint(this.theme, "accent", `${matched} matches`));
285
+ const separator = paint(this.theme, "meta", SEPARATORS.item);
286
+ return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
345
287
  }
346
288
 
347
289
  render(width: number): string[] {
@@ -351,77 +293,76 @@ export class WatchListComponent implements Component {
351
293
 
352
294
  private renderList(w: number): string[] {
353
295
  const watches = this.ordered;
354
- this.clampSelection();
355
- const index = this.indexOfSelected(watches);
296
+ this.cursor.clamp(watches, watchId);
297
+ const index = this.cursor.indexOf(watches, watchId);
356
298
  const focused = watches[index];
357
299
  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();
300
+ this.cursor.visible = fitRows(
301
+ this.viewportRowsSource,
302
+ watches.length,
303
+ LIST_CHROME_ROWS + detail.length,
304
+ SCREEN_DEFAULT_ROWS,
305
+ );
306
+ this.cursor.clamp(watches, watchId);
362
307
 
363
- const lines: string[] = [screenHeader(this.theme, w, "Watch"), this.summary(watches, w), ""];
308
+ const body: string[] = [];
309
+ let range: string | undefined;
364
310
  if (watches.length === 0) {
365
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No watchers.")}`, w));
311
+ body.push(...emptyState(this.theme, w, { label: "No watchers." }));
366
312
  } 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 }));
313
+ const end = Math.min(watches.length, this.cursor.scroll + this.cursor.visible);
314
+ for (let i = this.cursor.scroll; i < end; i++) body.push(watchRow(watches[i], this.theme, w, i === index));
315
+ if (this.cursor.scroll > 0 || end < watches.length) {
316
+ range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: watches.length });
373
317
  }
374
318
  }
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;
319
+
320
+ const hint = this.confirm
321
+ ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
322
+ : this.notice
323
+ ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
324
+ : screenHint(this.theme, w, [
325
+ "Esc close",
326
+ "↑/↓ or j/k select",
327
+ "Enter or l matches",
328
+ "x or K stop",
329
+ "d clear stopped",
330
+ "PgUp/PgDn",
331
+ ]);
332
+
333
+ return dockListFrame({
334
+ header: titleRule(this.theme, w, "Watch"),
335
+ summary: this.summary(watches, w),
336
+ body,
337
+ range,
338
+ // The detail pane is separated from the list by a blank line.
339
+ detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
340
+ hint,
341
+ });
394
342
  }
395
343
 
396
344
  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));
345
+ const visible = fitRows(this.viewportRowsSource, this.pane.count, LINES_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
346
+ this.pane.fit(visible);
347
+
348
+ const body: string[] = [];
349
+ let range: string | undefined;
350
+ if (this.pane.count === 0) {
351
+ body.push(...emptyState(this.theme, w, { label: "No matches yet." }));
411
352
  } 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
- );
353
+ const { start, end } = this.pane.window();
354
+ for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
355
+ if (start > 0 || end < this.pane.count) {
356
+ range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
420
357
  }
421
358
  }
422
- lines.push("");
423
- lines.push(screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]));
424
- lines.push("");
425
- return lines;
359
+
360
+ return dockListFrame({
361
+ header: titleRule(this.theme, w, this.pane.title("Watch Matches")),
362
+ summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
363
+ body,
364
+ range,
365
+ hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
366
+ });
426
367
  }
427
368
  }
@@ -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
  }