@gavin-hu/my-pi-agent 0.9.0 → 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 (132) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +21 -12
  79. package/extensions/job/format.ts +2 -2
  80. package/extensions/job/index.ts +25 -20
  81. package/extensions/job/tools.ts +5 -3
  82. package/extensions/job/tui.ts +13 -8
  83. package/extensions/memory/README.md +6 -0
  84. package/extensions/memory/format.ts +5 -3
  85. package/extensions/plan/README.md +32 -12
  86. package/extensions/plan/tools.ts +20 -5
  87. package/extensions/plan/web-client.ts +58 -17
  88. package/extensions/plan/web-page.ts +34 -9
  89. package/extensions/plan/web-router.ts +40 -9
  90. package/extensions/plan/web.ts +22 -2
  91. package/extensions/question/README.md +13 -1
  92. package/extensions/question/answers.ts +25 -18
  93. package/extensions/question/tools.ts +11 -6
  94. package/extensions/rewind/README.md +2 -0
  95. package/extensions/rewind/format.ts +6 -1
  96. package/extensions/rewind/index.ts +3 -1
  97. package/extensions/rewind/snapshot-run.ts +1 -1
  98. package/extensions/subagent/README.md +6 -0
  99. package/extensions/subagent/index.ts +1 -1
  100. package/extensions/subagent/render.ts +56 -29
  101. package/extensions/todo/README.md +19 -12
  102. package/extensions/todo/format.ts +17 -25
  103. package/extensions/todo/tools.ts +2 -1
  104. package/extensions/todo/tui.ts +3 -2
  105. package/extensions/watch/README.md +14 -6
  106. package/extensions/watch/format.ts +2 -2
  107. package/extensions/watch/index.ts +28 -23
  108. package/extensions/watch/tools.ts +5 -3
  109. package/extensions/watch/tui.ts +12 -7
  110. package/extensions/web-access/README.md +16 -7
  111. package/extensions/web-access/fetch/tool.ts +1 -1
  112. package/extensions/web-access/search/tool.ts +3 -3
  113. package/extensions/wechat/README.md +6 -0
  114. package/extensions/worktree/README.md +6 -0
  115. package/lib/README.md +2 -1
  116. package/lib/format.ts +17 -0
  117. package/lib/notice.ts +87 -0
  118. package/lib/tui/chrome.ts +8 -4
  119. package/lib/tui/cursor.ts +2 -1
  120. package/lib/tui/glyphs.ts +10 -11
  121. package/lib/tui/indent.ts +166 -0
  122. package/lib/tui/index.ts +3 -1
  123. package/lib/tui/rows.ts +39 -5
  124. package/lib/tui/rules.ts +9 -3
  125. package/lib/tui/tokens.ts +14 -0
  126. package/lib/web/base.ts +2 -1
  127. package/lib/web/components.ts +3 -1
  128. package/lib/web/layout.ts +5 -1
  129. package/package.json +7 -3
  130. package/themes/bytefly-dark.json +7 -8
  131. package/themes/bytefly-light.json +10 -11
  132. package/assets/bytefly-chrome.png +0 -0
@@ -15,8 +15,9 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
15
15
  import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
16
16
  import { sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts";
17
17
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
18
- import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
18
+ import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
19
  import { centeredRule } from "../../lib/tui/rules.ts";
20
+ import { BODY_INDENT } from "../../lib/tui/indent.ts";
20
21
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
21
22
  import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
22
23
  import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
@@ -187,7 +188,7 @@ function resultHeader(
187
188
  stepPrefix: string,
188
189
  now: number,
189
190
  ): string {
190
- let header = `${statusIcon(status, theme, now)} ${theme.fg("toolTitle", theme.bold(sanitizeLine(result.agent)))}`;
191
+ let header = `${statusIcon(status, theme, now)} ${paint(theme, "accent", theme.bold(sanitizeLine(result.agent)))}`;
191
192
  if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") {
192
193
  header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`);
193
194
  }
@@ -195,12 +196,13 @@ function resultHeader(
195
196
  return header + statusSuffix(result, status, theme, now);
196
197
  }
197
198
 
198
- /** Prefix each line of intermediate assistant text with a muted gutter. */
199
- function quoteLines(text: string, theme: Theme): string {
199
+ /** Prefix each line of intermediate assistant text with the block's body indent. */
200
+ function indentBody(text: string, theme: Theme): string {
201
+ const indent = " ".repeat(BODY_INDENT);
200
202
  return stripControlChars(text)
201
203
  .trim()
202
204
  .split("\n")
203
- .map((line) => paint(theme, "meta", "│ ") + theme.fg("toolOutput", line))
205
+ .map((line) => indent + theme.fg("toolOutput", line))
204
206
  .join("\n");
205
207
  }
206
208
 
@@ -233,47 +235,72 @@ export interface SubagentCallContext {
233
235
  lastComponent?: Component;
234
236
  }
235
237
 
238
+ /**
239
+ * The call's arguments as Pi hands them to `renderCall`. While the model streams
240
+ * a call, a field can still be missing, or arrive with a type the schema will
241
+ * later reject, so the call line reads every one defensively and never throws: a
242
+ * throwing renderer makes Pi fall back to its own generic call line.
243
+ */
244
+ export type StreamedSubagentArgs = Partial<Record<keyof SubagentArgs, unknown>>;
245
+
246
+ /** One field of a partially-streamed element, or undefined when it has not arrived. */
247
+ function streamedText(element: unknown, key: "agent" | "task" | "cwd"): string | undefined {
248
+ if (typeof element !== "object" || element === null) return undefined;
249
+ const value = (element as Record<string, unknown>)[key];
250
+ return typeof value === "string" ? value : undefined;
251
+ }
252
+
236
253
  /** Preview the requested mode and its agents, reusing the slot's `Text`. */
237
- export function renderSubagentCall(args: SubagentArgs, theme: Theme, context?: SubagentCallContext): Component {
254
+ export function renderSubagentCall(
255
+ args: StreamedSubagentArgs | undefined,
256
+ theme: Theme,
257
+ context?: SubagentCallContext,
258
+ ): Component {
238
259
  const text = reuseText(context?.lastComponent);
239
- text.setText(callText(args, theme, context));
260
+ text.setText(callText(args ?? {}, theme, context));
240
261
  return text;
241
262
  }
242
263
 
243
- /** The call-line string for the requested mode. */
244
- function callText(args: SubagentArgs, theme: Theme, context?: { cwd?: string }): string {
264
+ /**
265
+ * The call-line string for the requested mode. Continuation lines carry no
266
+ * indent of their own: the tool-block frame headlines the first line and
267
+ * indents the rest by `BODY_INDENT`.
268
+ */
269
+ function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: string }): string {
245
270
  const title =
246
271
  theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? paint(theme, "faint", "read-only ") : "");
247
- const cwdNote = (cwd?: string) => {
248
- if (!cwd || cwd === context?.cwd) return "";
272
+ const cwdNote = (cwd?: unknown) => {
273
+ if (typeof cwd !== "string" || !cwd || cwd === context?.cwd) return "";
249
274
  return paint(theme, "faint", ` in ${shortenPath(cwd)}`);
250
275
  };
251
-
252
- if (args.chain && args.chain.length > 0) {
253
- let text = title + paint(theme, "accent", `chain · ${args.chain.length} step${args.chain.length > 1 ? "s" : ""}`);
254
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
255
- const step = args.chain[i];
256
- const clean = sanitizeLine(step.task.replace(/\{previous\}/g, ""));
257
- text += `\n ${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "accent", sanitizeLine(step.agent))}${paint(theme, "faint", ` ${clip(clean, 40)}`)}${cwdNote(step.cwd)}`;
276
+ const chain = Array.isArray(args.chain) ? args.chain : [];
277
+ if (chain.length > 0) {
278
+ let text =
279
+ title + paint(theme, "label", `chain${SEPARATORS.item}${chain.length} step${chain.length > 1 ? "s" : ""}`);
280
+ for (let i = 0; i < Math.min(chain.length, 3); i++) {
281
+ const step = chain[i];
282
+ const task = (streamedText(step, "task") ?? "…").replace(/\{previous\}/g, "");
283
+ text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(step, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(task), 40)}`)}${cwdNote(streamedText(step, "cwd"))}`;
258
284
  }
259
- if (args.chain.length > 3) text += `\n ${paint(theme, "meta", `… +${args.chain.length - 3} more`)}`;
285
+ if (chain.length > 3) text += `\n${paint(theme, "meta", `… +${chain.length - 3} more`)}`;
260
286
  return text;
261
287
  }
262
288
 
263
- if (args.tasks && args.tasks.length > 0) {
289
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [];
290
+ if (tasks.length > 0) {
264
291
  let text =
265
- title + paint(theme, "accent", `parallel · ${args.tasks.length} task${args.tasks.length > 1 ? "s" : ""}`);
266
- for (let i = 0; i < Math.min(args.tasks.length, 3); i++) {
267
- const task = args.tasks[i];
268
- text += `\n ${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "accent", sanitizeLine(task.agent))}${paint(theme, "faint", ` ${clip(sanitizeLine(task.task), 40)}`)}${cwdNote(task.cwd)}`;
292
+ title + paint(theme, "label", `parallel${SEPARATORS.item}${tasks.length} task${tasks.length > 1 ? "s" : ""}`);
293
+ for (let i = 0; i < Math.min(tasks.length, 3); i++) {
294
+ const task = tasks[i];
295
+ text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(task, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(streamedText(task, "task") ?? "…"), 40)}`)}${cwdNote(streamedText(task, "cwd"))}`;
269
296
  }
270
- if (args.tasks.length > 3) text += `\n ${paint(theme, "meta", `… +${args.tasks.length - 3} more`)}`;
297
+ if (tasks.length > 3) text += `\n${paint(theme, "meta", `… +${tasks.length - 3} more`)}`;
271
298
  return text;
272
299
  }
273
300
 
274
- const agentName = sanitizeLine(args.agent || "…");
275
- let text = title + paint(theme, "accent", agentName) + cwdNote(args.cwd);
276
- text += `\n ${paint(theme, "faint", clip(sanitizeLine(args.task ?? "…"), 60))}`;
301
+ const agentName = sanitizeLine(streamedText(args, "agent") || "…");
302
+ let text = title + paint(theme, "label", agentName) + cwdNote(streamedText(args, "cwd"));
303
+ text += `\n${paint(theme, "meta", clip(sanitizeLine(streamedText(args, "task") ?? "…"), 60))}`;
277
304
  return text;
278
305
  }
279
306
 
@@ -316,7 +343,7 @@ function addExpandedResult(
316
343
  );
317
344
  renderedOutput = true;
318
345
  } else if (item.text.trim() && item.text !== trailingOutput) {
319
- container.addChild(new Text(quoteLines(item.text, theme), 0, 0));
346
+ container.addChild(new Text(indentBody(item.text, theme), 0, 0));
320
347
  renderedOutput = true;
321
348
  }
322
349
  }
@@ -8,12 +8,20 @@ about to end after mutating work without a todo update, the extension injects
8
8
  one short reminder and forces a continuation, so the list does not silently
9
9
  drift behind the work.
10
10
 
11
+ <img src="../../assets/todo.png" alt="The /todos screen: a Todos title rule, a 3/7 completed summary, and the list with check, spinner, and circle glyphs" width="100%">
12
+
11
13
  ```
12
14
  pi --extension ./extensions/todo # load just this extension
13
15
  pi -e . # load the whole @gavin-hu/my-pi-agent package
14
16
  pi install ./ # install the package
15
17
  ```
16
18
 
19
+ ## Tool rendering
20
+
21
+ How the `todo` call and its result look in the transcript — the call line as a headline and the body indented under it:
22
+
23
+ <img src="../../assets/tools/todo.png" alt="The todo tool's result: the call line and an indented body with the progress line and the task rows below it, each led by a check, spinner, or circle glyph" width="100%">
24
+
17
25
  ## What it does
18
26
 
19
27
  - Registers one model-callable tool, `todo`, that **replaces** the task list.
@@ -49,22 +57,20 @@ Todos · 1/3 · Writing the tests
49
57
  `/todos` is the expanded view and keeps the model's list order (use it to see
50
58
  the plan as written).
51
59
 
52
- In the transcript, the call line pairs progress with the active item
53
- (`todo → 1/4 · Writing tests`), and the result keeps the shared
54
- `✓` / `◒` / `◌` rail used by the goal result, separated from the call line by a
55
- blank line. Collapsed, it leads with active work; expanded, it keeps the
56
- submitted order:
60
+ In the transcript, the call line carries progress only (`todo → 1/4`); the
61
+ result keeps the `✓` / `◒` / `◌` rows, directly under the call line. The item
62
+ text lives on the rows, so the call line never repeats it. Collapsed, it leads
63
+ with active work; expanded, it keeps the submitted order:
57
64
 
58
65
  ```
59
- todo → 1/4 · Writing tests
60
-
66
+ todo → 1/4
61
67
  ◒ Writing tests
62
68
  ◌ Ship it
63
69
  ◌ Write docs
64
70
  ✓ Write schema
65
71
  ```
66
72
 
67
- Progress lives on the call line, so the rail has no footer; the persistent
73
+ Progress lives on the call line, so the rows have no footer; the persistent
68
74
  widget and `/todos` still show the count. A capped collapsed rail ends with
69
75
  `… N more` and the `app.tools.expand` hint.
70
76
 
@@ -204,10 +210,11 @@ depends on config.
204
210
  widget is a single line (`currentTodo` picks the `in_progress` item, else the
205
211
  first pending); `/todos` keeps the submitted order, and the transcript result
206
212
  orders by status via `compareByActivity` so active work is never hidden. The
207
- transcript result draws the same indented glyph rail as the goal result
208
- (`BODY_INDENT` / `GLYPH_GAP` from [`lib/tui/glyphs.ts`](../../lib/tui/glyphs.ts)), wrapping
209
- long items with continuation rows aligned under the text. Progress lives on
210
- the call line, so the rail carries no footer.
213
+ transcript result draws the glyph rows at the block's body indent (the frame
214
+ supplies `BODY_INDENT`; `GLYPH_GAP` comes from
215
+ [`lib/tui/glyphs.ts`](../../lib/tui/glyphs.ts)), wrapping long items with
216
+ continuation rows aligned under the text. Progress lives on the call line, so
217
+ the rows carry no footer.
211
218
  - **The transcript reuses its components.** `renderCall` reuses the slot's
212
219
  `Text`, and `renderResult` reuses the `TodoResult` view via
213
220
  `context.lastComponent`, updating each in place instead of allocating on every
@@ -3,8 +3,8 @@
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/tui/glyphs.ts";
6
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
8
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
9
9
  import { completedCount, currentTodo } from "./state.ts";
10
10
  import type { Todo, TodoStatus } from "./types.ts";
@@ -17,9 +17,6 @@ export function compareByActivity(a: Todo, b: Todo): number {
17
17
  return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
18
18
  }
19
19
 
20
- /** Longest first-item preview shown in the transcript call line. */
21
- const CALL_PREVIEW_WIDTH = 40;
22
-
23
20
  /** Plain status glyph per status; `todoGlyph` adds the theme color. */
24
21
  const STATUS_GLYPH: Record<TodoStatus, string> = {
25
22
  pending: GLYPHS.pending,
@@ -107,22 +104,16 @@ export function formatTodoText(todos: Todo[]): string {
107
104
  * One-line body for the transcript call renderer.
108
105
  *
109
106
  * The renderer owns the styled `todo ` title, so this returns only the body.
110
- * `todos` is `undefined` while the call's arguments are still streaming, which
111
- * is distinct from an empty list (a real clear). `argsComplete` disambiguates
112
- * 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.
113
112
  */
114
113
  export function formatCallText(todos: Todo[] | undefined, argsComplete = true): string {
115
114
  if (todos === undefined) return argsComplete ? "→ clear list" : "→ …";
116
115
  if (todos.length === 0) return "→ clear list";
117
-
118
- const current = currentTodo(todos);
119
- const label = current ? (current.activeForm ?? current.content).trim() : "completed";
120
- if (!label) return argsComplete ? `→ ${progressCount(todos)} · …` : "→ …";
121
- // `strict` drops a wide grapheme that would cross the boundary, so the slice
122
- // plus ellipsis never exceeds `CALL_PREVIEW_WIDTH` columns.
123
- const preview =
124
- visibleWidth(label) > CALL_PREVIEW_WIDTH ? `${sliceByColumn(label, 0, CALL_PREVIEW_WIDTH - 1, true)}…` : label;
125
- return `→ ${progressCount(todos)} · ${preview}`;
116
+ return `→ ${progressCount(todos)}`;
126
117
  }
127
118
 
128
119
  /** Rows and footer for the transcript result rail. */
@@ -134,18 +125,19 @@ export interface TodoRailInput {
134
125
  }
135
126
 
136
127
  /**
137
- * Transcript result rail: each row is indented by `BODY_INDENT`, led by its
138
- * status glyph, and its label wrapped with continuation rows aligned under the
139
- * text. A trailing `… N more` follows when rows were capped. Progress lives on
140
- * the call line, so the rail carries no footer. Every line is clipped to
141
- * `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.
142
135
  */
143
136
  export function todoRailLines(input: TodoRailInput, theme: Theme, width: number): string[] {
144
137
  const w = Math.max(1, width);
145
138
  // All three status glyphs are single-column, so the text column is fixed.
146
- const textColumn = BODY_INDENT + 1 + GLYPH_GAP;
139
+ const textColumn = 1 + GLYPH_GAP;
147
140
  const inner = Math.max(1, w - textColumn);
148
- const pad = " ".repeat(BODY_INDENT);
149
141
  const gap = " ".repeat(GLYPH_GAP);
150
142
  const continuation = " ".repeat(textColumn);
151
143
  const lines: string[] = [];
@@ -153,7 +145,7 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
153
145
  const glyph = todoGlyph(todo, theme);
154
146
  const wrapped = wrapTextWithAnsi(todoLabel(todo, theme), inner);
155
147
  wrapped.forEach((line, index) => {
156
- lines.push(index === 0 ? `${pad}${glyph}${gap}${line}` : `${continuation}${line}`);
148
+ lines.push(index === 0 ? `${glyph}${gap}${line}` : `${continuation}${line}`);
157
149
  });
158
150
  }
159
151
  if (input.more && input.more > 0) {
@@ -14,6 +14,7 @@ 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 { GLYPHS } from "../../lib/tui/glyphs.ts";
17
18
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
18
19
  import { paint } from "../../lib/tui/tokens.ts";
19
20
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
@@ -118,7 +119,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
118
119
  return text;
119
120
  }
120
121
  if (details.todos.length === 0) {
121
- text.setText(paint(theme, "ok", "✓ ") + paint(theme, "meta", "Cleared the todo list"));
122
+ text.setText(paint(theme, "ok", `${GLYPHS.check} `) + paint(theme, "meta", "Cleared the todo list"));
122
123
  return text;
123
124
  }
124
125
 
@@ -12,6 +12,7 @@ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDel
12
12
  import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
13
13
  import { clip } from "../../lib/tui/columns.ts";
14
14
  import { emptyState, listRow } from "../../lib/tui/rows.ts";
15
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
15
16
  import { titleRule } from "../../lib/tui/rules.ts";
16
17
  import { paint } from "../../lib/tui/tokens.ts";
17
18
  import {
@@ -50,10 +51,10 @@ function todoRow(todo: Todo, theme: Theme, width: number): string {
50
51
  * `jobs` widgets. Callers clip it to the available width.
51
52
  */
52
53
  function todoLine(todos: Todo[], theme: Theme): string {
53
- const head = `${paint(theme, "accent", "Todos")} ${paint(theme, "faint", "·")} ${paint(theme, "faint", progressCount(todos))}`;
54
+ const head = `${paint(theme, "accent", "Todos")}${paint(theme, "faint", SEPARATORS.item)}${paint(theme, "faint", progressCount(todos))}`;
54
55
  const current = currentTodo(todos);
55
56
  if (!current) return `${head} ${paint(theme, "faint", "completed")}`;
56
- return `${head} ${paint(theme, "faint", "·")} ${todoLabel(current, theme)}`;
57
+ return `${head}${paint(theme, "faint", SEPARATORS.item)}${todoLabel(current, theme)}`;
57
58
  }
58
59
 
59
60
  /** Transcript result block: the indented glyph rail, wrapping at the render width. */
@@ -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,7 +201,7 @@ 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`. |
@@ -12,7 +12,7 @@
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/tui/glyphs.ts";
15
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
16
16
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
17
17
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
18
18
 
@@ -195,7 +195,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
195
195
 
196
196
  /** One-line outcome for a watcher, themed for the transcript result. */
197
197
  export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = Date.now()): string {
198
- const sep = paint(theme, "faint", " · ");
198
+ const sep = paint(theme, "faint", SEPARATORS.item);
199
199
  const age = Math.max(0, (watch.endedAt ?? now) - watch.startedAt);
200
200
  const outcome =
201
201
  watch.status === "error"
@@ -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";
15
+ import { sanitizeLine } from "../../lib/format.ts";
16
+ import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
16
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) => {
@@ -10,6 +10,7 @@
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
12
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
13
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
13
14
  import { paint } from "../../lib/tui/tokens.ts";
14
15
  import {
15
16
  compareWatches,
@@ -193,8 +194,9 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
193
194
  const text = reuseText(context.lastComponent);
194
195
  text.setText(
195
196
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
196
- theme.fg(
197
- "muted",
197
+ paint(
198
+ theme,
199
+ "meta",
198
200
  sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
199
201
  ),
200
202
  );
@@ -269,7 +271,7 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
269
271
  const badges: string[] = [];
270
272
  if (details.watch.wake) badges.push("wake");
271
273
  if (details.watch.fromStart) badges.push("from start");
272
- if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
274
+ if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
273
275
  }
274
276
  text.setText(line);
275
277
  return text;
@@ -18,7 +18,7 @@ import { clip, fitCells } from "../../lib/tui/columns.ts";
18
18
  import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
19
19
  import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
20
20
  import { LinePane } from "../../lib/tui/line-pane.ts";
21
- import { emptyState, listRow } from "../../lib/tui/rows.ts";
21
+ import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
22
22
  import { titleRule } from "../../lib/tui/rules.ts";
23
23
  import { paint } from "../../lib/tui/tokens.ts";
24
24
  import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
@@ -37,14 +37,18 @@ const LINES_CHROME_ROWS = 6;
37
37
  const watchId = (watch: WatchRecord): string => watch.id;
38
38
 
39
39
  /** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
40
- function watchRow(watch: WatchRecord, theme: Theme, width: number, selected = false): string {
41
- return listRow(theme, width, {
42
- selected,
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,
43
48
  glyph: statusGlyphChar(watch.status),
44
49
  glyphRole: statusRole(watch.status),
45
50
  label: `${watch.id} ${shortLabel(watch)}`,
46
51
  trailing: `${watch.matchCount}`,
47
- trailingRole: "faint",
48
52
  });
49
53
  }
50
54
 
@@ -80,7 +84,7 @@ export class WatchResult implements Component {
80
84
  // A leading blank line separates the result from the call header.
81
85
  const lines: string[] = [""];
82
86
  if (this.input.kind === "list") {
83
- 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 }));
84
88
  if (this.input.more > 0) {
85
89
  const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
86
90
  lines.push(clip(` ${note}`, w));
@@ -311,7 +315,8 @@ export class WatchListComponent implements Component {
311
315
  body.push(...emptyState(this.theme, w, { label: "No watchers." }));
312
316
  } else {
313
317
  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));
318
+ for (let i = this.cursor.scroll; i < end; i++)
319
+ body.push(watchRow(watches[i], this.theme, w, { selected: i === index }));
315
320
  if (this.cursor.scroll > 0 || end < watches.length) {
316
321
  range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: watches.length });
317
322
  }
@@ -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
@@ -205,7 +205,7 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
205
205
  renderCall(args, theme, context) {
206
206
  const { url, find, startIndex } = args as WebFetchArgs;
207
207
  const text = reuseText(context?.lastComponent);
208
- let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "accent", oneLine(url ?? ""));
208
+ let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "label", oneLine(url ?? ""));
209
209
  if (find?.length) line += paint(theme, "faint", ` find ${find.map((f) => `"${oneLine(f)}"`).join(", ")}`);
210
210
  else if (startIndex) line += paint(theme, "faint", ` (from ${startIndex})`);
211
211
  text.setText(line);