@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,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
- import { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import { BODY_INDENT, expandHint, GLYPH_GAP, GLYPHS } from "../../lib/ui.ts";
6
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
8
9
  import { completedCount, currentTodo } from "./state.ts";
9
10
  import type { Todo, TodoStatus } from "./types.ts";
10
11
 
@@ -16,9 +17,6 @@ export function compareByActivity(a: Todo, b: Todo): number {
16
17
  return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
17
18
  }
18
19
 
19
- /** Longest first-item preview shown in the transcript call line. */
20
- const CALL_PREVIEW_WIDTH = 40;
21
-
22
20
  /** Plain status glyph per status; `todoGlyph` adds the theme color. */
23
21
  const STATUS_GLYPH: Record<TodoStatus, string> = {
24
22
  pending: GLYPHS.pending,
@@ -26,23 +24,43 @@ const STATUS_GLYPH: Record<TodoStatus, string> = {
26
24
  completed: GLYPHS.check,
27
25
  };
28
26
 
29
- /** Themed status glyph, shared by the widget and the transcript renderer. */
30
- export function todoGlyph(todo: Todo, theme: Theme): string {
27
+ /** The design role a status glyph is drawn in. */
28
+ export function todoGlyphRole(todo: Todo): TuiRole {
31
29
  switch (todo.status) {
32
30
  case "completed":
33
- return theme.fg("success", STATUS_GLYPH.completed);
31
+ return "ok";
34
32
  case "in_progress":
35
- return theme.fg("accent", STATUS_GLYPH.in_progress);
33
+ return "accent";
36
34
  default:
37
- return theme.fg("dim", STATUS_GLYPH.pending);
35
+ return "faint";
38
36
  }
39
37
  }
40
38
 
39
+ /** The status glyph, from the shared vocabulary. */
40
+ export function todoGlyphChar(todo: Todo): string {
41
+ return STATUS_GLYPH[todo.status];
42
+ }
43
+
44
+ /** The design role an item label is drawn in. */
45
+ export function todoLabelRole(todo: Todo): TuiRole {
46
+ if (todo.status === "completed") return "faint";
47
+ if (todo.status === "in_progress") return "label";
48
+ return "meta";
49
+ }
50
+
51
+ /** The item text: the active form while in progress, the content otherwise. */
52
+ export function todoLabelText(todo: Todo): string {
53
+ return todo.status === "in_progress" ? (todo.activeForm ?? todo.content) : todo.content;
54
+ }
55
+
56
+ /** Themed status glyph, shared by the widget and the transcript renderer. */
57
+ export function todoGlyph(todo: Todo, theme: Theme): string {
58
+ return paint(theme, todoGlyphRole(todo), todoGlyphChar(todo));
59
+ }
60
+
41
61
  /** Themed item text: completed dim, in-progress accented, pending muted. */
42
62
  export function todoLabel(todo: Todo, theme: Theme): string {
43
- if (todo.status === "completed") return theme.fg("dim", todo.content);
44
- if (todo.status === "in_progress") return theme.fg("text", todo.activeForm ?? todo.content);
45
- return theme.fg("muted", todo.content);
63
+ return paint(theme, todoLabelRole(todo), todoLabelText(todo));
46
64
  }
47
65
 
48
66
  /** "2/5" — completed over total. */
@@ -86,22 +104,16 @@ export function formatTodoText(todos: Todo[]): string {
86
104
  * One-line body for the transcript call renderer.
87
105
  *
88
106
  * The renderer owns the styled `todo ` title, so this returns only the body.
89
- * `todos` is `undefined` while the call's arguments are still streaming, which
90
- * is distinct from an empty list (a real clear). `argsComplete` disambiguates
91
- * the tail end of the stream.
107
+ * Progress is the summary; the result rail lists the items (active work first),
108
+ * so the call line does not repeat the current item. `todos` is `undefined`
109
+ * while the call's arguments are still streaming, which is distinct from an
110
+ * empty list (a real clear). `argsComplete` disambiguates the tail end of the
111
+ * stream.
92
112
  */
93
113
  export function formatCallText(todos: Todo[] | undefined, argsComplete = true): string {
94
114
  if (todos === undefined) return argsComplete ? "→ clear list" : "→ …";
95
115
  if (todos.length === 0) return "→ clear list";
96
-
97
- const current = currentTodo(todos);
98
- const label = current ? (current.activeForm ?? current.content).trim() : "completed";
99
- if (!label) return argsComplete ? `→ ${progressCount(todos)} · …` : "→ …";
100
- // `strict` drops a wide grapheme that would cross the boundary, so the slice
101
- // plus ellipsis never exceeds `CALL_PREVIEW_WIDTH` columns.
102
- const preview =
103
- visibleWidth(label) > CALL_PREVIEW_WIDTH ? `${sliceByColumn(label, 0, CALL_PREVIEW_WIDTH - 1, true)}…` : label;
104
- return `→ ${progressCount(todos)} · ${preview}`;
116
+ return `→ ${progressCount(todos)}`;
105
117
  }
106
118
 
107
119
  /** Rows and footer for the transcript result rail. */
@@ -113,18 +125,19 @@ export interface TodoRailInput {
113
125
  }
114
126
 
115
127
  /**
116
- * Transcript result rail: each row is indented by `BODY_INDENT`, led by its
117
- * status glyph, and its label wrapped with continuation rows aligned under the
118
- * text. A trailing `… N more` follows when rows were capped. Progress lives on
119
- * the call line, so the rail carries no footer. Every line is clipped to
120
- * `width`.
128
+ * Transcript result rail: each row is led by its status glyph, and its label
129
+ * wrapped with continuation rows aligned under the text. A trailing `… N more`
130
+ * follows when rows were capped. Progress lives on the call line, so the rail
131
+ * carries no footer. Every line is clipped to `width`.
132
+ *
133
+ * The rail draws at its own left edge; the tool-block frame supplies
134
+ * `BODY_INDENT`, so the rail must not add it again.
121
135
  */
122
136
  export function todoRailLines(input: TodoRailInput, theme: Theme, width: number): string[] {
123
137
  const w = Math.max(1, width);
124
138
  // All three status glyphs are single-column, so the text column is fixed.
125
- const textColumn = BODY_INDENT + 1 + GLYPH_GAP;
139
+ const textColumn = 1 + GLYPH_GAP;
126
140
  const inner = Math.max(1, w - textColumn);
127
- const pad = " ".repeat(BODY_INDENT);
128
141
  const gap = " ".repeat(GLYPH_GAP);
129
142
  const continuation = " ".repeat(textColumn);
130
143
  const lines: string[] = [];
@@ -132,11 +145,11 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
132
145
  const glyph = todoGlyph(todo, theme);
133
146
  const wrapped = wrapTextWithAnsi(todoLabel(todo, theme), inner);
134
147
  wrapped.forEach((line, index) => {
135
- lines.push(index === 0 ? `${pad}${glyph}${gap}${line}` : `${continuation}${line}`);
148
+ lines.push(index === 0 ? `${glyph}${gap}${line}` : `${continuation}${line}`);
136
149
  });
137
150
  }
138
151
  if (input.more && input.more > 0) {
139
- lines.push(`${continuation}${theme.fg("dim", `… ${input.more} more`)} ${expandHint(theme)}`);
152
+ lines.push(`${continuation}${paint(theme, "faint", `… ${input.more} more`)} ${expandHint(theme)}`);
140
153
  }
141
154
  return lines.map((line) => truncateToWidth(line, w, "…"));
142
155
  }
@@ -14,7 +14,9 @@ import type { JsonValue } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { sanitizeLine } from "../../lib/format.ts";
16
16
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
- import { reuseText, toolDetails } from "../../lib/tui.ts";
17
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
19
+ import { paint } from "../../lib/tui/tokens.ts";
18
20
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
19
21
  import type { TodoRuntime } from "./runtime.ts";
20
22
  import { normalizeTodos, TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
@@ -97,7 +99,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
97
99
  const text = reuseText(context.lastComponent);
98
100
  text.setText(
99
101
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
100
- theme.fg("muted", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
102
+ paint(theme, "meta", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
101
103
  );
102
104
  return text;
103
105
  },
@@ -106,7 +108,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
106
108
  const text = reuseText(context.lastComponent);
107
109
  const details = toolDetails<TodoDetails>(result.details);
108
110
  if (details?.error) {
109
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
111
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
110
112
  return text;
111
113
  }
112
114
  // A call rejected before `execute` (argument validation or a permission
@@ -117,7 +119,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
117
119
  return text;
118
120
  }
119
121
  if (details.todos.length === 0) {
120
- text.setText(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared the todo list"));
122
+ text.setText(paint(theme, "ok", `${GLYPHS.check} `) + paint(theme, "meta", "Cleared the todo list"));
121
123
  return text;
122
124
  }
123
125
 
@@ -8,10 +8,24 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateToWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
11
- import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDelta } from "../../lib/list-cursor.ts";
12
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
13
- import { BODY_INDENT } from "../../lib/ui.ts";
14
- import { progressCount, progressSummary, todoGlyph, todoLabel, todoRailLines, type TodoRailInput } from "./format.ts";
11
+ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDelta } from "../../lib/tui/cursor.ts";
12
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
13
+ import { clip } from "../../lib/tui/columns.ts";
14
+ import { emptyState, listRow } from "../../lib/tui/rows.ts";
15
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
+ import { titleRule } from "../../lib/tui/rules.ts";
17
+ import { paint } from "../../lib/tui/tokens.ts";
18
+ import {
19
+ progressCount,
20
+ progressSummary,
21
+ todoGlyphChar,
22
+ todoGlyphRole,
23
+ todoLabel,
24
+ todoLabelRole,
25
+ todoLabelText,
26
+ todoRailLines,
27
+ type TodoRailInput,
28
+ } from "./format.ts";
15
29
  import { currentTodo } from "./state.ts";
16
30
  import type { Todo } from "./types.ts";
17
31
 
@@ -23,8 +37,12 @@ const SCREEN_DEFAULT_ITEMS = 12;
23
37
 
24
38
  /** One indented item line, clipped to `width`. */
25
39
  function todoRow(todo: Todo, theme: Theme, width: number): string {
26
- const indent = " ".repeat(BODY_INDENT);
27
- return truncateToWidth(`${indent}${todoGlyph(todo, theme)} ${todoLabel(todo, theme)}`, width, "…");
40
+ return listRow(theme, width, {
41
+ glyph: todoGlyphChar(todo),
42
+ glyphRole: todoGlyphRole(todo),
43
+ label: todoLabelText(todo),
44
+ labelRole: todoLabelRole(todo),
45
+ });
28
46
  }
29
47
 
30
48
  /**
@@ -33,10 +51,10 @@ function todoRow(todo: Todo, theme: Theme, width: number): string {
33
51
  * `jobs` widgets. Callers clip it to the available width.
34
52
  */
35
53
  function todoLine(todos: Todo[], theme: Theme): string {
36
- const head = `${theme.fg("accent", "Todos")} ${theme.fg("dim", "·")} ${theme.fg("dim", progressCount(todos))}`;
54
+ const head = `${paint(theme, "accent", "Todos")}${paint(theme, "faint", SEPARATORS.item)}${paint(theme, "faint", progressCount(todos))}`;
37
55
  const current = currentTodo(todos);
38
- if (!current) return `${head} ${theme.fg("dim", "completed")}`;
39
- return `${head} ${theme.fg("dim", "·")} ${todoLabel(current, theme)}`;
56
+ if (!current) return `${head} ${paint(theme, "faint", "completed")}`;
57
+ return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
40
58
  }
41
59
 
42
60
  /** Transcript result block: the indented glyph rail, wrapping at the render width. */
@@ -139,32 +157,34 @@ export class TodoListComponent implements Component {
139
157
 
140
158
  render(width: number): string[] {
141
159
  const w = Math.max(1, width);
142
- const lines: string[] = [screenHeader(this.theme, w, "Todos")];
160
+ const header = titleRule(this.theme, w, "Todos");
143
161
 
144
162
  if (this.todos.length === 0) {
145
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todos yet. Ask the agent to plan some work.")}`, w));
146
- lines.push("");
147
- lines.push(screenHint(this.theme, w, ["Esc close"]));
148
- lines.push("");
149
- return lines;
163
+ return dockListFrame({
164
+ header,
165
+ body: emptyState(this.theme, w, { label: "No todos yet. Ask the agent to plan some work." }),
166
+ hint: screenHint(this.theme, w, ["Esc close"]),
167
+ });
150
168
  }
151
169
 
152
- lines.push(truncateToWidth(` ${this.theme.fg("muted", progressSummary(this.todos))}`, w));
153
- lines.push("");
154
170
  // header, summary, blank, blank-after-list, hint, blank
155
171
  const chrome = 6;
156
172
  this.visible = fitRows(this.viewportRowsSource, this.todos.length, chrome, SCREEN_DEFAULT_ITEMS);
157
173
  this.scrollTop = clampScroll(this.scrollTop, this.todos.length, this.visible);
158
174
 
159
175
  const end = Math.min(this.todos.length, this.scrollTop + this.visible);
160
- for (let i = this.scrollTop; i < end; i++) lines.push(todoRow(this.todos[i], this.theme, w));
161
- if (this.scrollTop > 0 || end < this.todos.length) {
162
- lines.push(formatRange(this.theme, w, { start: this.scrollTop, end, total: this.todos.length }));
163
- }
164
-
165
- lines.push("");
166
- lines.push(screenHint(this.theme, w, ["Esc close", "↑/↓ or k/j scroll", "PgUp/PgDn", "g/G or Home/End"]));
167
- lines.push("");
168
- return lines;
176
+ const body: string[] = [];
177
+ for (let i = this.scrollTop; i < end; i++) body.push(todoRow(this.todos[i], this.theme, w));
178
+
179
+ return dockListFrame({
180
+ header,
181
+ summary: clip(` ${paint(this.theme, "meta", progressSummary(this.todos))}`, w),
182
+ body,
183
+ range:
184
+ this.scrollTop > 0 || end < this.todos.length
185
+ ? formatRange(this.theme, w, { start: this.scrollTop, end, total: this.todos.length })
186
+ : undefined,
187
+ hint: screenHint(this.theme, w, ["Esc close", "↑/↓ or k/j scroll", "PgUp/PgDn", "g/G or Home/End"]),
188
+ });
169
189
  }
170
190
  }
@@ -11,6 +11,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent pack
11
11
  pi install ./ # install the package
12
12
  ```
13
13
 
14
+ ## Tool rendering
15
+
16
+ How the `watch` call and its result look in the transcript — the call line as a headline and the body indented under it:
17
+
18
+ <img src="../../assets/tools/watch.png" alt="The watch tool's result: the call line and an indented body with a running file watcher and an exited command watcher" width="100%">
19
+
14
20
  ## What it does
15
21
 
16
22
  - Registers one model-callable tool, `watch`, with actions `add`, `list`,
@@ -85,9 +91,11 @@ and the command's `pid`/`exitCode`/`signal` when applicable.
85
91
 
86
92
  ### Transcript
87
93
 
88
- `add` and `stop` render one outcome line; `list` renders a `/watch`-style rail
89
- capped at eight rows; `matches` and `logs` render the tail of the sanitized
90
- lines, with the hidden count noted above and `(ctrl+o to expand)`.
94
+ `add` and `stop` render one outcome line; `list` renders the `/watch` rows
95
+ capped at eight rows, minus the selection-marker column (`listRow`'s
96
+ `marker: false`) that only the interactive screen needs; `matches` and `logs`
97
+ render the tail of the sanitized lines, with the hidden count noted above and
98
+ `(ctrl+o to expand)`.
91
99
 
92
100
  ### Command: /watch
93
101
 
@@ -172,8 +180,8 @@ other.
172
180
  | Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. |
173
181
  | `outputSchema` | None; the result carries `details` only (no `structuredContent` mirror). |
174
182
  | State storage | In-memory runtime closure; no registry, no `getBranch()` rebuild. |
175
- | Injected context | A hidden `watch-context` custom message; pending matches are read from the runtime, never from the transcript. |
176
- | Lifecycle | `session_start` loads config and re-arms `onMatch`; `session_tree` repaints; `agent_start`/`agent_settled` track busy; `before_agent_start` injects pending matches; `context` keeps the newest note; `session_shutdown` kills commands and clears chips. |
183
+ | Injected context | A shared transcript notice (`my-pi-agent/notice`), rendered by bytefly; pending matches are read from the runtime, never from the transcript. |
184
+ | Lifecycle | `session_start` loads config and re-arms `onMatch`; `session_tree` repaints; `agent_start`/`agent_settled` track busy; `before_agent_start` posts pending matches as a notice; `context` prunes stale notices of its own source; `session_shutdown` kills commands and clears chips. |
177
185
 
178
186
  `watch` is gated by `PI_DISABLED_EXTENSIONS` like every extension.
179
187
 
@@ -193,15 +201,15 @@ turn — so a match is never dropped when the wake window is busy.
193
201
 
194
202
  | Module | Responsibility |
195
203
  |---|---|
196
- | `index.ts` | Extension factory: registers the tool and command, wires the lifecycle and injected context. |
204
+ | `index.ts` | Extension factory: registers the tool and command, wires the lifecycle and the shared notices. |
197
205
  | `tools.ts` | The `watch` tool definition, execution, and transcript rendering. |
198
206
  | `schema.ts` | TypeBox parameters and pure `normalizeCall` validation. |
199
207
  | `types.ts` | `WatchAction`/`WatchStatus`, `WatchRecord`, `WatchDetails`, `PendingMatch`. |
200
208
  | `runtime.ts` | The session-scoped watcher table, matcher, poll tick, and shutdown. |
201
209
  | `process.ts` | Injectable spawn and process-tree kill. |
202
210
  | `format.ts` | Pure sanitization and text formatting. |
203
- | `ui.ts` | Status chips and the match alert. |
204
- | `tui.ts` | `WatchResult` transcript block and the `/watch` dock screen. |
211
+ | `ui.ts` | Status chips and the match alert, over the shared `lib/tui/status-controller.ts`. |
212
+ | `tui.ts` | `WatchResult` transcript block and the `/watch` dock screen, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
205
213
  | `commands.ts` | The `/watch` command and its completions. |
206
214
  | `config.ts` | The `watch` config section load and normalization. |
207
215
 
@@ -12,7 +12,8 @@
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { sanitizeLine } from "../../lib/format.ts";
15
- import { GLYPHS } from "../../lib/ui.ts";
15
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
16
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
16
17
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
17
18
 
18
19
  /** Longest label shown in the transcript or `/watch` screen. */
@@ -63,20 +64,39 @@ export function shortLabel(watch: WatchRecord): string {
63
64
  return clipLabel(watch.label || sourceLabel(watch));
64
65
  }
65
66
 
66
- /** Width-1 themed glyph for a watcher status. */
67
- export function statusGlyph(status: WatchStatus, theme: Theme): string {
67
+ /** The design role a watcher status is drawn in. */
68
+ export function statusRole(status: WatchStatus): TuiRole {
69
+ switch (status) {
70
+ case "exited":
71
+ return "ok";
72
+ case "error":
73
+ return "bad";
74
+ case "stopped":
75
+ return "meta";
76
+ default:
77
+ return "accent";
78
+ }
79
+ }
80
+
81
+ /** Width-1 glyph for a watcher status, from the shared vocabulary. */
82
+ export function statusGlyphChar(status: WatchStatus): string {
68
83
  switch (status) {
69
84
  case "exited":
70
- return theme.fg("success", GLYPHS.check);
85
+ return GLYPHS.check;
71
86
  case "error":
72
- return theme.fg("error", GLYPHS.warn);
87
+ return GLYPHS.warn;
73
88
  case "stopped":
74
- return theme.fg("muted", GLYPHS.stopped);
89
+ return GLYPHS.stopped;
75
90
  default:
76
- return theme.fg("accent", GLYPHS.target);
91
+ return GLYPHS.target;
77
92
  }
78
93
  }
79
94
 
95
+ /** Width-1 themed glyph for a watcher status. */
96
+ export function statusGlyph(status: WatchStatus, theme: Theme): string {
97
+ return paint(theme, statusRole(status), statusGlyphChar(status));
98
+ }
99
+
80
100
  /** Short status word, including an exit code or error when present. */
81
101
  export function statusWord(watch: WatchRecord): string {
82
102
  if (watch.status === "error" && watch.error) return `error (${watch.error})`;
@@ -175,22 +195,22 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
175
195
 
176
196
  /** One-line outcome for a watcher, themed for the transcript result. */
177
197
  export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = Date.now()): string {
178
- const sep = theme.fg("dim", " · ");
198
+ const sep = paint(theme, "faint", SEPARATORS.item);
179
199
  const age = Math.max(0, (watch.endedAt ?? now) - watch.startedAt);
180
200
  const outcome =
181
201
  watch.status === "error"
182
- ? theme.fg("error", statusWord(watch))
183
- : theme.fg("dim", `${watch.matchCount} match${watch.matchCount === 1 ? "" : "es"}`);
202
+ ? paint(theme, "bad", statusWord(watch))
203
+ : paint(theme, "faint", `${watch.matchCount} match${watch.matchCount === 1 ? "" : "es"}`);
184
204
  return (
185
205
  statusGlyph(watch.status, theme) +
186
206
  " " +
187
- theme.fg("accent", watch.id) +
207
+ paint(theme, "accent", watch.id) +
188
208
  " " +
189
- theme.fg("muted", shortLabel(watch)) +
209
+ paint(theme, "meta", shortLabel(watch)) +
190
210
  sep +
191
211
  outcome +
192
212
  sep +
193
- theme.fg("dim", `${Math.round(age / 1000)}s`)
213
+ paint(theme, "faint", `${Math.round(age / 1000)}s`)
194
214
  );
195
215
  }
196
216
 
@@ -3,19 +3,20 @@
3
3
  *
4
4
  * Registers the `watch` tool and `/watch` command. Watchers read a file or a
5
5
  * command's merged output, match each new line against a regular expression,
6
- * and alert the user; matching lines are reported to the model at the next
7
- * turn, or wake a turn immediately when the watcher opted into `wake`. Watchers
8
- * are session-only and stopped on shutdown.
6
+ * and alert the user; matching lines are reported to the model — and shown as a
7
+ * bytefly notice — at the next turn, or wake a turn immediately when the
8
+ * watcher opted into `wake`. Watchers are session-only and stopped on shutdown.
9
9
  *
10
10
  * Load with: pi --extension ./extensions/watch
11
11
  */
12
12
 
13
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
14
  import { isExtensionEnabled } from "../../lib/env.ts";
16
- import { STATUS_KEYS } from "../../lib/ui.ts";
15
+ import { sanitizeLine } from "../../lib/format.ts";
16
+ import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
17
+ import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
18
  import { registerCommands } from "./commands.ts";
18
- import { formatPendingMatch } from "./format.ts";
19
+ import { clipLabel, formatPendingMatch } from "./format.ts";
19
20
  import { createWatchRuntime, type WatchRuntime } from "./runtime.ts";
20
21
  import { registerTools } from "./tools.ts";
21
22
  import type { PendingMatch } from "./types.ts";
@@ -23,13 +24,10 @@ import type { PendingMatch } from "./types.ts";
23
24
  /** Marker embedded in the injected match context. */
24
25
  const WATCH_CONTEXT_MARKER = "[WATCH MATCHES]";
25
26
 
26
- /** Custom-entry type used for the injected match message. */
27
- export const WATCH_CONTEXT_TYPE = "watch-context";
28
-
29
27
  /** At most this many match lines are injected in one message. */
30
28
  const MAX_CONTEXT_MATCHES = 20;
31
29
 
32
- /** Build the hidden context injected for pending matches. */
30
+ /** Build the model-facing text for the pending-match notice. */
33
31
  export function buildWatchContext(matches: PendingMatch[]): string {
34
32
  const shown = matches.slice(0, MAX_CONTEXT_MATCHES);
35
33
  const lines = shown.map(formatPendingMatch);
@@ -38,8 +36,21 @@ export function buildWatchContext(matches: PendingMatch[]): string {
38
36
  return `${WATCH_CONTEXT_MARKER}\n${matches.length} watched line${matches.length === 1 ? "" : "s"} matched:\n${lines.join("\n")}${suffix}\nRead more with \`watch matches <id>\`.`;
39
37
  }
40
38
 
41
- function isWatchContext(message: AgentMessage): boolean {
42
- return (message as AgentMessage & { customType?: string }).customType === WATCH_CONTEXT_TYPE;
39
+ /** The shared notice for pending matches: model text plus renderer details. */
40
+ function watchNotice(matches: PendingMatch[]): NoticeMessage {
41
+ const count = matches.length;
42
+ return noticeMessage({
43
+ content: buildWatchContext(matches),
44
+ details: {
45
+ source: "watch",
46
+ title: `${count} watched line${count === 1 ? "" : "s"} matched`,
47
+ items: matches.slice(0, MAX_CONTEXT_MATCHES).map((match) => ({
48
+ label: `${match.watcherId} ${clipLabel(match.label)}`,
49
+ detail: sanitizeLine(match.line),
50
+ severity: "warn",
51
+ })),
52
+ },
53
+ });
43
54
  }
44
55
 
45
56
  export interface WatchDeps {
@@ -64,10 +75,7 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
64
75
  const pending = runtime.pendingMatches();
65
76
  if (pending.length === 0) return;
66
77
  try {
67
- pi.sendMessage(
68
- { customType: WATCH_CONTEXT_TYPE, content: buildWatchContext(pending), display: false },
69
- { triggerTurn: true },
70
- );
78
+ pi.sendMessage(watchNotice(pending), { triggerTurn: true });
71
79
  runtime.markReported(pending);
72
80
  waking = true;
73
81
  } catch {
@@ -103,17 +111,14 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
103
111
  if (pending.length === 0) return undefined;
104
112
  runtime.markReported(pending);
105
113
  runtime.setStatus(ctx);
106
- return {
107
- message: { customType: WATCH_CONTEXT_TYPE, content: buildWatchContext(pending), display: false },
108
- };
114
+ return { message: watchNotice(pending) };
109
115
  });
110
116
 
111
- // Keep stale match notes out of later turns.
117
+ // Keep stale match notices out of later turns. Only this extension's notices
118
+ // are pruned; another source's are left for its own handler.
112
119
  pi.on("context", (event) => {
113
- const contexts = event.messages.filter(isWatchContext);
114
- if (contexts.length <= 1) return undefined;
115
- const last = contexts[contexts.length - 1];
116
- return { messages: event.messages.filter((message) => !isWatchContext(message) || message === last) };
120
+ const pruned = pruneStaleNotices(event.messages, "watch");
121
+ return pruned ? { messages: pruned } : undefined;
117
122
  });
118
123
 
119
124
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -9,7 +9,9 @@
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 { SEPARATORS } from "../../lib/tui/glyphs.ts";
14
+ import { paint } from "../../lib/tui/tokens.ts";
13
15
  import {
14
16
  compareWatches,
15
17
  formatCallText,
@@ -192,8 +194,9 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
192
194
  const text = reuseText(context.lastComponent);
193
195
  text.setText(
194
196
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
195
- theme.fg(
196
- "muted",
197
+ paint(
198
+ theme,
199
+ "meta",
197
200
  sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
198
201
  ),
199
202
  );
@@ -209,13 +212,13 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
209
212
  return text;
210
213
  }
211
214
  if (details.error) {
212
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
215
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
213
216
  return text;
214
217
  }
215
218
 
216
219
  if (details.action === "list" && details.watches) {
217
220
  if (details.watches.length === 0) {
218
- text.setText(theme.fg("muted", "No watchers."));
221
+ text.setText(paint(theme, "meta", "No watchers."));
219
222
  return text;
220
223
  }
221
224
  const ordered = [...details.watches].sort(compareWatches);
@@ -232,7 +235,7 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
232
235
  const all = raw === "" ? [] : raw.split("\n");
233
236
  const label = details.action === "matches" ? "matches" : "output";
234
237
  if (all.length === 0) {
235
- text.setText(theme.fg("muted", `No ${label} yet.`));
238
+ text.setText(paint(theme, "meta", `No ${label} yet.`));
236
239
  return text;
237
240
  }
238
241
  const shownLines = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
@@ -251,7 +254,11 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
251
254
  if (details.action === "clear") {
252
255
  const count = details.cleared ?? 0;
253
256
  text.setText(
254
- theme.fg("muted", count === 0 ? "Nothing to clear." : `Cleared ${count} watcher${count === 1 ? "" : "s"}.`),
257
+ paint(
258
+ theme,
259
+ "meta",
260
+ count === 0 ? "Nothing to clear." : `Cleared ${count} watcher${count === 1 ? "" : "s"}.`,
261
+ ),
255
262
  );
256
263
  return text;
257
264
  }
@@ -259,12 +266,12 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
259
266
  if (details.watch) {
260
267
  let line = formatWatchOutcomeLine(details.watch, theme);
261
268
  if (details.action === "stop") {
262
- line += theme.fg("warning", details.stopped ? " · stopped" : " · already stopped");
269
+ line += paint(theme, "warn", details.stopped ? " · stopped" : " · already stopped");
263
270
  } else if (details.action === "add") {
264
271
  const badges: string[] = [];
265
272
  if (details.watch.wake) badges.push("wake");
266
273
  if (details.watch.fromStart) badges.push("from start");
267
- if (badges.length > 0) line += theme.fg("dim", ` · ${badges.join(" · ")}`);
274
+ if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
268
275
  }
269
276
  text.setText(line);
270
277
  return text;