@gavin-hu/my-pi-agent 0.9.0 → 0.11.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 (140) 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 +113 -35
  79. package/extensions/job/commands.ts +1 -0
  80. package/extensions/job/config.ts +4 -0
  81. package/extensions/job/format.ts +19 -7
  82. package/extensions/job/index.ts +25 -20
  83. package/extensions/job/process.ts +24 -2
  84. package/extensions/job/registry.ts +3 -0
  85. package/extensions/job/runtime.ts +253 -18
  86. package/extensions/job/schema.ts +34 -0
  87. package/extensions/job/store.ts +13 -1
  88. package/extensions/job/tools.ts +55 -15
  89. package/extensions/job/tui.ts +41 -15
  90. package/extensions/job/types.ts +9 -1
  91. package/extensions/memory/README.md +6 -0
  92. package/extensions/memory/format.ts +5 -3
  93. package/extensions/plan/README.md +32 -12
  94. package/extensions/plan/tools.ts +20 -5
  95. package/extensions/plan/web-client.ts +58 -17
  96. package/extensions/plan/web-page.ts +34 -9
  97. package/extensions/plan/web-router.ts +40 -9
  98. package/extensions/plan/web.ts +22 -2
  99. package/extensions/question/README.md +13 -1
  100. package/extensions/question/answers.ts +25 -18
  101. package/extensions/question/tools.ts +11 -6
  102. package/extensions/rewind/README.md +2 -0
  103. package/extensions/rewind/format.ts +6 -1
  104. package/extensions/rewind/index.ts +3 -1
  105. package/extensions/rewind/snapshot-run.ts +1 -1
  106. package/extensions/subagent/README.md +6 -0
  107. package/extensions/subagent/index.ts +1 -1
  108. package/extensions/subagent/render.ts +56 -29
  109. package/extensions/todo/README.md +19 -12
  110. package/extensions/todo/format.ts +17 -25
  111. package/extensions/todo/tools.ts +2 -1
  112. package/extensions/todo/tui.ts +3 -2
  113. package/extensions/watch/README.md +14 -6
  114. package/extensions/watch/format.ts +2 -2
  115. package/extensions/watch/index.ts +28 -23
  116. package/extensions/watch/tools.ts +5 -3
  117. package/extensions/watch/tui.ts +12 -7
  118. package/extensions/web-access/README.md +16 -7
  119. package/extensions/web-access/fetch/tool.ts +1 -1
  120. package/extensions/web-access/search/tool.ts +3 -3
  121. package/extensions/wechat/README.md +6 -0
  122. package/extensions/worktree/README.md +6 -0
  123. package/lib/README.md +2 -1
  124. package/lib/format.ts +17 -0
  125. package/lib/notice.ts +87 -0
  126. package/lib/tui/chrome.ts +8 -4
  127. package/lib/tui/cursor.ts +2 -1
  128. package/lib/tui/glyphs.ts +10 -11
  129. package/lib/tui/indent.ts +166 -0
  130. package/lib/tui/index.ts +3 -1
  131. package/lib/tui/rows.ts +39 -5
  132. package/lib/tui/rules.ts +9 -3
  133. package/lib/tui/tokens.ts +14 -0
  134. package/lib/web/base.ts +2 -1
  135. package/lib/web/components.ts +3 -1
  136. package/lib/web/layout.ts +5 -1
  137. package/package.json +7 -3
  138. package/themes/bytefly-dark.json +7 -8
  139. package/themes/bytefly-light.json +10 -11
  140. package/assets/bytefly-chrome.png +0 -0
@@ -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);
@@ -69,8 +69,8 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
69
69
  renderCall(args, theme, context) {
70
70
  const { query, maxResults } = args as WebSearchArgs;
71
71
  const text = reuseText(context?.lastComponent);
72
- let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "accent", oneLine(query ?? ""));
73
- if (maxResults) line += paint(theme, "faint", ` (${maxResults} results)`);
72
+ let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "label", oneLine(query ?? ""));
73
+ if (maxResults) line += paint(theme, "faint", ` (max ${maxResults})`);
74
74
  text.setText(line);
75
75
  return text;
76
76
  },
@@ -96,7 +96,7 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
96
96
  paint(theme, "faint", `via ${details.provider}${SEPARATORS.item}${count} result${count === 1 ? "" : "s"}`),
97
97
  ];
98
98
  if (details.answer)
99
- lines.push(`${paint(theme, "faint", "answer")} ${paint(theme, "meta", oneLine(details.answer, 160))}`);
99
+ lines.push(`${paint(theme, "faint", "Answer:")} ${paint(theme, "meta", oneLine(details.answer, 160))}`);
100
100
 
101
101
  const shown = options.expanded ? details.results : details.results.slice(0, 5);
102
102
  const indent = paint(theme, "faint", " ");
@@ -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 `send_wechat` 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/send_wechat.png" alt="The send_wechat tool's call and result: the call line and an indented body with the message text and the sent confirmation" width="100%">
19
+
14
20
  ## What it does
15
21
 
16
22
  - **Inbound** — a long-poll loop receives WeChat text, images, and files and
@@ -14,6 +14,12 @@ pi install ./ # install the package
14
14
  pi -e . --worktree feature-auth # load the package and enter a worktree at startup
15
15
  ```
16
16
 
17
+ ## Tool rendering
18
+
19
+ How the `list_worktrees` call and its result look in the transcript — the call line as a headline and the body indented under it:
20
+
21
+ <img src="../../assets/tools/list_worktrees.png" alt="The list_worktrees tool's call and result, drawn by the generic fallback" width="100%">
22
+
17
23
  ## What it does
18
24
 
19
25
  - `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
package/lib/README.md CHANGED
@@ -20,11 +20,12 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
20
20
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
21
21
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
22
22
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` and `JobFinishedEvent`, published by `job` and consumed by bytefly's notify capability. |
23
+ | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
23
24
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — and `shortenPath`. |
24
25
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote adapter installs on `ctx.ui` under a string key, the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file/text preface) plus the shared “Other” label. |
25
26
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
26
27
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
27
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `BODY_INDENT` / `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (`plainRule` / `titleRule` / `centeredRule`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
29
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
29
30
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
30
31
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
package/lib/format.ts CHANGED
@@ -17,6 +17,23 @@ export function formatTokens(count: number): string {
17
17
  return `${Math.round(count / 1000000)}M`;
18
18
  }
19
19
 
20
+ /**
21
+ * An integer grouped with thousands separators (`999`, `8,000`, `42,478`).
22
+ * Hand-rolled rather than `toLocaleString`, so a count is identical under any
23
+ * locale and ICU build.
24
+ */
25
+ export function formatCount(count: number): string {
26
+ if (!Number.isFinite(count)) return "0";
27
+ const sign = count < 0 ? "-" : "";
28
+ const digits = Math.abs(Math.trunc(count)).toString();
29
+ let out = "";
30
+ for (let i = 0; i < digits.length; i++) {
31
+ if (i > 0 && (digits.length - i) % 3 === 0) out += ",";
32
+ out += digits[i];
33
+ }
34
+ return sign + out;
35
+ }
36
+
20
37
  /**
21
38
  * Replace terminal control characters (including ESC and the C1 block) with
22
39
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.