@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
@@ -1,20 +1,16 @@
1
1
  /**
2
2
  * Terminal rendering for the goal.
3
3
  *
4
- * Two surfaces share one vocabulary. `goalWidgetLines` renders the persistent
5
- * above-editor widget as a single label-first rail line (`Goal · active ·
6
- * <objective>`, dimmed once achieved), matching the `todo` and `jobs` widgets.
7
- * `goalRailLines` renders the transcript result as a header (`Goal · active`)
8
- * plus the objective wrapped and led by the status glyph, with continuation rows
9
- * aligned under the text; it matches the todo transcript result's indent + glyph
10
- * grammar so the two read as a pair.
11
- * `GoalResult` wraps `goalRailLines` uncapped for the transcript.
4
+ * Two surfaces share one vocabulary. The persistent above-editor widget is a
5
+ * single label-first rail line (`Goal · active · <objective>`, dimmed once
6
+ * achieved), matching the `todo` and `jobs` widgets. The transcript result is
7
+ * the status header alone (`Goal · active`): the call line already names the
8
+ * objective, so the result only states the new status.
12
9
  */
13
10
 
14
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
16
- import { BODY_INDENT, GLYPH_GAP } from "../../lib/tui/glyphs.ts";
17
- import { goalGlyph, goalHeader, goalLine } from "./format.ts";
12
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { goalHeader, goalLine } from "./format.ts";
18
14
  import type { AchievedStyle, Goal } from "./types.ts";
19
15
 
20
16
  /** Widget key used with `ctx.ui.setWidget()`. */
@@ -25,27 +21,6 @@ export interface GoalWidgetOptions {
25
21
  achieved?: AchievedStyle;
26
22
  }
27
23
 
28
- /**
29
- * Rows for the transcript goal rail: the themed header plus `body` wrapped and
30
- * led by the status glyph. Continuation rows are padded to the text column.
31
- */
32
- export function goalRailLines(goal: Goal, body: string, theme: Theme, width: number): string[] {
33
- const w = Math.max(1, width);
34
- const glyph = goalGlyph(goal, theme);
35
- const glyphWidth = visibleWidth(glyph);
36
- const textColumn = BODY_INDENT + glyphWidth + GLYPH_GAP;
37
- const inner = Math.max(1, w - textColumn);
38
-
39
- const wrapped = wrapTextWithAnsi(body, inner);
40
- const pad = " ".repeat(BODY_INDENT);
41
- const continuation = " ".repeat(textColumn);
42
- const lines = [goalHeader(goal, theme)];
43
- wrapped.forEach((line, index) => {
44
- lines.push(index === 0 ? `${pad}${glyph}${" ".repeat(GLYPH_GAP)}${line}` : `${continuation}${line}`);
45
- });
46
- return lines.map((line) => truncateToWidth(line, w, "…"));
47
- }
48
-
49
24
  /** Rows for the goal widget: a single rail line, or nothing when hidden. */
50
25
  export function goalWidgetLines(goal: Goal, theme: Theme, width: number, options: GoalWidgetOptions = {}): string[] {
51
26
  const w = Math.max(1, width);
@@ -68,30 +43,27 @@ export class GoalWidget implements Component {
68
43
  }
69
44
  }
70
45
 
71
- /** Transcript result block: the rail, uncapped, with the themed body. */
46
+ /** Transcript result block: the status header, separated from the call line. */
72
47
  export class GoalResult implements Component {
73
48
  private goal: Goal;
74
- private body: string;
75
49
  private theme: Theme;
76
50
 
77
- constructor(goal: Goal, body: string, theme: Theme) {
51
+ constructor(goal: Goal, theme: Theme) {
78
52
  this.goal = goal;
79
- this.body = body;
80
53
  this.theme = theme;
81
54
  }
82
55
 
83
56
  /** Update in place so the transcript can reuse this component across renders. */
84
- update(goal: Goal, body: string, theme: Theme): void {
57
+ update(goal: Goal, theme: Theme): void {
85
58
  this.goal = goal;
86
- this.body = body;
87
59
  this.theme = theme;
88
60
  }
89
61
 
90
62
  invalidate(): void {}
91
63
 
92
64
  render(width: number): string[] {
93
- // The leading blank line separates the rail from the call header, matching
65
+ // The leading blank line separates the header from the call line, matching
94
66
  // the built-ins' header/output gap.
95
- return ["", ...goalRailLines(this.goal, this.body, this.theme, Math.max(1, width))];
67
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
96
68
  }
97
69
  }
@@ -5,12 +5,20 @@ the background and manage them without blocking the turn. A job starts
5
5
  immediately and keeps running across tool calls; poll it, tail its output, wait
6
6
  for it, or kill it.
7
7
 
8
+ <img src="../../assets/job.png" alt="The /jobs screen: a summary line, the job rows with status glyphs and elapsed times, and the focused job's command, cwd, and last output line" width="100%">
9
+
8
10
  ```
9
11
  pi --extension ./extensions/job # load just this extension
10
12
  pi -e . # load the whole @gavin-hu/my-pi-agent package
11
13
  pi install ./ # install the package
12
14
  ```
13
15
 
16
+ ## Tool rendering
17
+
18
+ How the `job` call and its result look in the transcript — the call line as a headline and the body indented under it:
19
+
20
+ <img src="../../assets/tools/job.png" alt="The job tool's result: the call line and an indented body with a finished and a failed job row, each with its status glyph and elapsed time" width="100%">
21
+
14
22
  ## What it does
15
23
 
16
24
  - Registers one model-callable tool, `job`, with actions `start`, `list`,
@@ -81,7 +89,7 @@ JobDetails {
81
89
  ### Transcript
82
90
 
83
91
  The call line names the action and its target. The result is themed and reuses
84
- the slot's component: `list` draws the same row rail as `/jobs`, `logs` draws a
92
+ the slot's component: `list` draws the same rows as `/jobs`, `logs` draws a
85
93
  blank line and the sanitized tail, and a single-job action draws one outcome
86
94
  line. A `kill` that sent a signal appends it (`· SIGTERM sent`) instead of
87
95
  showing the job's pre-kill running state, a `start` badges `detached` and
@@ -89,7 +97,6 @@ showing the job's pre-kill running state, a `start` badges `detached` and
89
97
 
90
98
  ```
91
99
  job list
92
-
93
100
  ▸ j1 tests 3.2s
94
101
  ✗ j2 build 0.4s
95
102
  ```
@@ -199,8 +206,8 @@ rewritten.
199
206
  | Execution mode | `sequential` — actions share the in-memory job table. |
200
207
  | Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true` — plan mode blocks it. |
201
208
  | `outputSchema` / `structuredContent` | None. Results return `JobDetails` in tool-result `details` only. |
202
- | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as hidden `job-context` messages at `before_agent_start`. |
203
- | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale `job-context` messages. |
209
+ | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as a shared transcript notice (`my-pi-agent/notice`) at `before_agent_start`. |
210
+ | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale notices of its own source. |
204
211
  | Event bus | Each job that reaches a terminal state is published on `my-pi-agent/job-finished` (`lib/events.ts`) with its id, label, status, exit code, and signal, from the one `settle` path every transition goes through. bytefly's notify capability subscribes there instead of importing this extension. |
205
212
 
206
213
  ## Design notes
@@ -248,11 +255,11 @@ rewritten.
248
255
  settles it (recovering an exit code from the status file, else `unknown`)
249
256
  when the pid disappears.
250
257
  - **Notify by default, wake opt-in.** A failed job raises one `ctx.ui.notify`
251
- (a clean exit is left to the list and chips). Finished jobs are also injected
252
- as a hidden `job-context` message at the next `before_agent_start`. A
253
- `wake: true` job (or `wakeOnFinish`) additionally triggers one turn when the
254
- agent is idle; auto-wake is not the default because it easily loops and burns
255
- tokens.
258
+ (a clean exit is left to the list and chips). Finished jobs are also posted
259
+ as a shared transcript notice — model-facing text plus the notice block — at the
260
+ next `before_agent_start`. A `wake: true` job (or `wakeOnFinish`) additionally
261
+ triggers one turn when the agent is idle; auto-wake is not the default because
262
+ it easily loops and burns tokens.
256
263
  - **Width-1 glyphs and cached latest line.** The running chip is `▸ N` and the
257
264
  failure chip `✗ N`, each a two-token badge under its own status key, so the
258
265
  status bar compacts each independently (`▸N` / `✗N`) and an unreported
@@ -264,8 +271,10 @@ rewritten.
264
271
  spawn/liveness/kill/clock functions, so the whole suite runs without
265
272
  launching a process.
266
273
  - **The transcript reuses the `/jobs` grammar.** `job list` renders the same
267
- `jobRow` rail as the screen (running first, capped, `… N more`), `job logs`
268
- draws a themed tail with a `… N earlier lines` note, and single-job actions
274
+ `jobRow` rows as the screen (running first, capped, `… N more`), minus the
275
+ selection-marker column (`listRow`'s `marker: false`), which only the
276
+ interactive screen needs; `job logs` draws a themed tail with a `… N earlier
277
+ lines` note, and single-job actions
269
278
  draw one `formatJobOutcomeLine` (plus a signal note on `kill` and
270
279
  `detached`/`wake` badges on `start`). A capped collapsed result ends with the
271
280
  `app.tools.expand` hint, and an expanded list shows every job. The renderers
@@ -292,7 +301,7 @@ rewritten.
292
301
  | `ui.ts` | Footer status chips, over the shared `lib/tui/status-controller.ts`. |
293
302
  | `waiters.ts` | `wait` resolver bookkeeping. |
294
303
  | `runtime.ts` | Composition: job table, handles, process events, repaint clock. |
295
- | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
304
+ | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rows, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
296
305
  | `tools.ts` | The `job` tool and its transcript rendering. |
297
306
  | `commands.ts` | The `/jobs` command. |
298
307
  | `types.ts` | Shared types. |
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth } from "@earendil-works/pi-tui";
17
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
17
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
18
18
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
19
19
  import type { JobRecord, JobStatus } from "./types.ts";
20
20
 
@@ -251,7 +251,7 @@ export function jobOutcome(job: JobRecord): string {
251
251
 
252
252
  /** One-line outcome for a single job, themed for the transcript result. */
253
253
  export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.now()): string {
254
- const sep = paint(theme, "faint", " · ");
254
+ const sep = paint(theme, "faint", SEPARATORS.item);
255
255
  const outcome =
256
256
  job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job));
257
257
  return (
@@ -4,18 +4,19 @@
4
4
  * Registers the `job` tool and `/jobs` command. Jobs run as detached shell
5
5
  * processes with their output streamed to log files; the status chip shows
6
6
  * running and unreported-failure counts, and finished jobs are reported to the
7
- * model at the next turn (or, for `wake` jobs, by triggering one turn).
7
+ * model — and shown as a bytefly notice — at the next turn, or by triggering one
8
+ * turn for a `wake` job.
8
9
  *
9
10
  * Load with: pi --extension ./extensions/job
10
11
  */
11
12
 
12
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { isExtensionEnabled } from "../../lib/env.ts";
15
15
  import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.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 { formatCompletion } from "./format.ts";
19
+ import { formatCompletion, jobOutcome, shortLabel } from "./format.ts";
19
20
  import { createJobsRuntime, type JobsRuntime } from "./runtime.ts";
20
21
  import { registerTools } from "./tools.ts";
21
22
  import type { Job, JobRecord } from "./types.ts";
@@ -23,16 +24,26 @@ import type { Job, JobRecord } from "./types.ts";
23
24
  /** Marker embedded in the injected completion context. */
24
25
  const JOB_CONTEXT_MARKER = "[BACKGROUND JOBS]";
25
26
 
26
- /** Custom-entry type used for the injected completion message. */
27
- export const JOB_CONTEXT_TYPE = "job-context";
28
-
29
27
  function buildContext(jobs: JobRecord[]): string {
30
28
  const lines = jobs.map((job) => formatCompletion(job));
31
29
  return `${JOB_CONTEXT_MARKER}\n${jobs.length} background job${jobs.length === 1 ? "" : "s"} finished:\n${lines.join("\n")}\nRun \`job logs <id>\` for output.`;
32
30
  }
33
31
 
34
- function isJobContext(message: AgentMessage): boolean {
35
- return (message as AgentMessage & { customType?: string }).customType === JOB_CONTEXT_TYPE;
32
+ /** The shared notice for pending completions: model text plus renderer details. */
33
+ function jobNotice(jobs: JobRecord[]): NoticeMessage {
34
+ const count = jobs.length;
35
+ return noticeMessage({
36
+ content: buildContext(jobs),
37
+ details: {
38
+ source: "job",
39
+ title: `${count} background job${count === 1 ? "" : "s"} finished`,
40
+ items: jobs.map((job) => ({
41
+ label: `${job.id} ${shortLabel(job)}`,
42
+ detail: jobOutcome(job),
43
+ severity: job.status === "failed" ? "bad" : job.status === "exited" ? "ok" : "warn",
44
+ })),
45
+ },
46
+ });
36
47
  }
37
48
 
38
49
  /** Trim a finished record to the fields the event bus carries. */
@@ -67,10 +78,7 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
67
78
  const pending = runtime.pendingCompletions();
68
79
  if (pending.length === 0) return;
69
80
  try {
70
- pi.sendMessage(
71
- { customType: JOB_CONTEXT_TYPE, content: buildContext(pending), display: false },
72
- { triggerTurn: true },
73
- );
81
+ pi.sendMessage(jobNotice(pending), { triggerTurn: true });
74
82
  // Mark only once the send succeeded; a throw leaves them for the next turn.
75
83
  runtime.markReported(pending.map((job) => job.id));
76
84
  } catch {
@@ -113,17 +121,14 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
113
121
  if (pending.length === 0) return undefined;
114
122
  runtime.markReported(pending.map((job) => job.id));
115
123
  runtime.setStatus(ctx);
116
- return {
117
- message: { customType: JOB_CONTEXT_TYPE, content: buildContext(pending), display: false },
118
- };
124
+ return { message: jobNotice(pending) };
119
125
  });
120
126
 
121
- // Keep stale completion notes out of later turns.
127
+ // Keep stale completion notices out of later turns. Only this extension's
128
+ // notices are pruned; another source's are left for its own handler.
122
129
  pi.on("context", (event) => {
123
- const contexts = event.messages.filter(isJobContext);
124
- if (contexts.length <= 1) return undefined;
125
- const last = contexts[contexts.length - 1];
126
- return { messages: event.messages.filter((message) => !isJobContext(message) || message === last) };
130
+ const pruned = pruneStaleNotices(event.messages, "job");
131
+ return pruned ? { messages: pruned } : undefined;
127
132
  });
128
133
 
129
134
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -10,6 +10,7 @@
10
10
  import type { ExtensionAPI } 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
  compareJobs,
@@ -209,8 +210,9 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
209
210
  const text = reuseText(context.lastComponent);
210
211
  text.setText(
211
212
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
212
- theme.fg(
213
- "muted",
213
+ paint(
214
+ theme,
215
+ "meta",
214
216
  sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
215
217
  ),
216
218
  );
@@ -282,7 +284,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
282
284
  const badges: string[] = [];
283
285
  if (details.job.detached) badges.push("detached");
284
286
  if (details.job.wake) badges.push("wake");
285
- if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
287
+ if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
286
288
  }
287
289
  text.setText(line);
288
290
  return text;
@@ -21,7 +21,7 @@ import { clip, fitCells } from "../../lib/tui/columns.ts";
21
21
  import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
22
22
  import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
23
23
  import { LinePane } from "../../lib/tui/line-pane.ts";
24
- import { emptyState, listRow } from "../../lib/tui/rows.ts";
24
+ import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
25
25
  import { titleRule } from "../../lib/tui/rules.ts";
26
26
  import { paint } from "../../lib/tui/tokens.ts";
27
27
  import {
@@ -51,15 +51,19 @@ const LOG_CHROME_ROWS = 6;
51
51
  const jobId = (job: JobRecord): string => job.id;
52
52
 
53
53
  /** One job row: selection marker, status glyph, id, label, and a right-aligned
54
- * elapsed value. `listRow` owns the clipping and the alignment. */
55
- function jobRow(job: JobRecord, theme: Theme, width: number, selected = false): string {
56
- return listRow(theme, width, {
57
- selected,
54
+ * elapsed value. `statusRow` owns the clipping and the alignment. */
55
+ function jobRow(
56
+ job: JobRecord,
57
+ theme: Theme,
58
+ width: number,
59
+ options: Pick<StatusRow, "selected" | "marker"> = {},
60
+ ): string {
61
+ return statusRow(theme, width, {
62
+ ...options,
58
63
  glyph: statusGlyphChar(job.status),
59
64
  glyphRole: statusRole(job.status),
60
65
  label: `${job.id} ${shortLabel(job)}`,
61
66
  trailing: formatDuration(elapsedMs(job, Date.now())),
62
- trailingRole: "faint",
63
67
  });
64
68
  }
65
69
 
@@ -95,7 +99,7 @@ export class JobResult implements Component {
95
99
  // A leading blank line separates the result from the call header.
96
100
  const lines: string[] = [""];
97
101
  if (this.input.kind === "list") {
98
- for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w));
102
+ for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w, { marker: false }));
99
103
  if (this.input.more > 0) {
100
104
  const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
101
105
  lines.push(clip(` ${note}`, w));
@@ -350,7 +354,8 @@ export class JobListComponent implements Component {
350
354
  body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
351
355
  } else {
352
356
  const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
353
- for (let i = this.cursor.scroll; i < end; i++) body.push(jobRow(jobs[i], this.theme, w, i === index));
357
+ for (let i = this.cursor.scroll; i < end; i++)
358
+ body.push(jobRow(jobs[i], this.theme, w, { selected: i === index }));
354
359
  if (this.cursor.scroll > 0 || end < jobs.length) {
355
360
  range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
356
361
  }
@@ -13,6 +13,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent pac
13
13
  pi install ./ # install the package
14
14
  ```
15
15
 
16
+ ## Tool rendering
17
+
18
+ How the `memory` call and its result look in the transcript — the call line as a headline and the body indented under it:
19
+
20
+ <img src="../../assets/tools/memory.png" alt="The memory tool's result: the call line and an indented body confirming the write and the new project and global counts" width="100%">
21
+
16
22
  ## What it does
17
23
 
18
24
  - Registers one model-callable tool, `memory`, with four actions: `add` a note,
@@ -78,7 +78,9 @@ export function formatResultText(details: MemoryDetails, expanded = false): stri
78
78
  ].join("\n");
79
79
  case "add":
80
80
  if (details.changed) {
81
- return `Added to ${scopeLabel(details.scope)} memory: ${details.entry ?? ""}\nNow ${countLine(entries)}.`;
81
+ // The call line already names the note, so the result confirms the write
82
+ // and the new counts rather than repeating it.
83
+ return `Added to ${scopeLabel(details.scope)} memory\nNow ${countLine(entries)}.`;
82
84
  }
83
85
  if (details.existing !== undefined && details.existing !== details.entry) {
84
86
  return `Already in ${scopeLabel(details.scope)} memory (near-duplicate): ${details.existing}`;
@@ -86,10 +88,10 @@ export function formatResultText(details: MemoryDetails, expanded = false): stri
86
88
  return `Already in ${scopeLabel(details.scope)} memory: ${details.existing ?? details.entry ?? ""}`;
87
89
  case "update":
88
90
  return details.changed
89
- ? `Updated ${scopeLabel(details.scope)} memory: ${details.entry ?? ""} → ${details.to ?? ""}\nNow ${countLine(entries)}.`
91
+ ? `Updated ${scopeLabel(details.scope)} memory\nNow ${countLine(entries)}.`
90
92
  : `Already set in ${scopeLabel(details.scope)} memory: ${details.entry ?? ""}`;
91
93
  case "forget":
92
- return `Removed from ${scopeLabel(details.scope)} memory: ${details.entry ?? ""}\nNow ${countLine(entries)}.`;
94
+ return `Removed from ${scopeLabel(details.scope)} memory\nNow ${countLine(entries)}.`;
93
95
  }
94
96
  }
95
97
 
@@ -12,6 +12,14 @@ pi -e . # load the whole @gavin-hu/my-pi-agent packag
12
12
  pi --plan # start in plan mode
13
13
  ```
14
14
 
15
+ ## Tool rendering
16
+
17
+ How the `write_plan` and `exit_plan_mode` 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/write_plan.png" alt="The write_plan tool's call and result: the call line and an indented body with the saved plan path and byte count" width="100%">
20
+
21
+ <img src="../../assets/tools/exit_plan_mode.png" alt="The exit_plan_mode tool's call and result: the call line and an indented body with the approval line and the approved plan preview" width="100%">
22
+
15
23
  ## What it does
16
24
 
17
25
  - **Read-only gating, with one exception.** Tools that are not read-only are
@@ -142,7 +150,9 @@ the user.
142
150
 
143
151
  Browser review is a local-turn feature: the server binds `127.0.0.1`, so a remote
144
152
  (WeChat) turn cannot reach it and `/plan review` there refuses with a notice
145
- instead of opening a server nobody can use.
153
+ instead of opening a server nobody can use. The browser's **Approve & run** is
154
+ offered only for the review `exit_plan_mode` opens, since only that flow owns
155
+ leaving plan mode; `/plan review` stays comment-only.
146
156
 
147
157
  ### Transcript
148
158
 
@@ -254,15 +264,15 @@ agent file that overrides it.
254
264
 
255
265
  ## Browser review
256
266
 
257
- <img src="../../assets/plan-review.png" alt="The browser-based plan review: a sticky header with the plan title and Save/Close, a side panel with step progress and a section outline, and the plan as one measured column with a comment rail and an inline review thread" width="100%">
267
+ <img src="../../assets/plan-review.png" alt="The browser-based plan review: a sticky header with the plan title, a status chip, and the Approve & run, Save feedback, and Back to Pi actions, a side panel with step progress and a section outline, and the plan as one measured column with a comment rail and an inline review thread" width="100%">
258
268
 
259
269
  Press `w` on the review screen — or run `/plan review [path]` — to open the plan
260
270
  in the default browser on `127.0.0.1`. The page is a reading-first review
261
271
  surface: a sticky header carrying the plan's H1 (falling back to the file-name
262
272
  slug), a live status chip (connection, comment count, and unsaved changes), the
263
- theme toggle, and Save/Close, over a hairline reading-progress bar. A sticky
264
- side panel shows the plan's step progress and comment count above a **section
265
- outline**, and the document is GitHub's Markdown content column — one centred
273
+ theme toggle, and the review actions, over a hairline reading-progress bar. A
274
+ sticky side panel shows the plan's step progress and comment count above a
275
+ **section outline**, and the document is GitHub's Markdown content column — one centred
266
276
  measure of at most 1012px with a comment rail in its left margin. Every block
267
277
  reveals a **+ Comment** action on hover; select
268
278
  text first to quote it. **Save feedback** writes the comments into the plan
@@ -284,12 +294,22 @@ file and returns them to the model.
284
294
  labelled and indented as a thread, so it reads in place and the plan keeps one
285
295
  measure at every width. There is no separate column to reflow.
286
296
  - **Keyboard first.** `j`/`k` move to the next or previous block, `c` comments
287
- on the current block, `s` saves, `Esc` closes the composer, and `?` opens a
288
- shortcut sheet; the header's `?` button opens the same sheet.
297
+ on the current block, `a` approves (the approval flow only), `s` saves, `Esc`
298
+ closes the composer or the sheet and then the review, and `?` opens a shortcut
299
+ sheet; the header's `?` button opens the same sheet.
300
+ - **Decision in place.** A review opened for approval (`w` on the review screen)
301
+ shows **Approve & run** beside Save feedback: it persists the current comments,
302
+ ends plan mode, restores write access, seeds `todo`, and hands control back to
303
+ the model. **Save feedback** keeps plan mode and returns the comments as a
304
+ refinement; **Back to Pi** closes the review with no decision. Each action
305
+ settles the page on a terminal state — `Approved`, `Saved`, or `Closed` — so
306
+ the tab never falls to Offline. `/plan review` is comment-only: it renders no
307
+ approve action and the server refuses one.
289
308
  - **Markdown that reads.** Headings, lists, task items, blockquotes, fenced
290
- code, GFM tables, `---` rules, and bare-URL autolinks render; tables honour the
291
- alignment row and
292
- scroll horizontally when narrow.
309
+ code, GFM tables, `---` rules, and bare-URL autolinks render; a list item's
310
+ continuation lines hang under the item's text, inline code is a soft fill
311
+ rather than a bordered chip, tables honour the alignment row and scroll
312
+ horizontally when narrow.
293
313
  - **Highlighted code.** Fenced code is tokenized by the shared
294
314
  `lib/highlight.ts`; the client copies only text and `tok-*` spans through
295
315
  `DOMParser`, so a code line is never parsed as markup.
@@ -439,10 +459,10 @@ file and returns them to the model.
439
459
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, section jump, scroll gauge, approve/refine/browser/keep). |
440
460
  | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, tables, rules, blanks). |
441
461
  | `feedback.ts` | Browser-comment codec: `stripFeedback`, `parseFeedback`, `formatFeedback`, `feedbackSummary`, and payload validation. |
442
- | `web.ts` | `createPlanWebReview`: the loopback server, the section outline and code highlighting, and the save/cancel/abort session. |
462
+ | `web.ts` | `createPlanWebReview`: the loopback server, the section outline and code highlighting, and the save/approve/cancel/abort session. |
443
463
  | `web-router.ts` | Pure request handler: token/`Host`/method checks and the review routes. |
444
464
  | `web-page.ts` | The review page shell (header, reading progress, review facts, section outline, shortcut sheet), its data island, and inline CSS. |
445
- | `web-client.ts` | The `/client.js` source (block/thread rendering, the comment rail, outline and facts, inline comments, selection quotes, tables, highlighted code, keyboard nav, save/cancel). |
465
+ | `web-client.ts` | The `/client.js` source (block/thread rendering, the comment rail, outline and facts, inline comments, selection quotes, tables, highlighted code, keyboard nav, save/approve/back). |
446
466
  | `path-guard.ts` | Recursive path-argument detection for the read-only backstop. |
447
467
  | [`../../lib/browser.ts`](../../lib/browser.ts) | Cross-platform default-browser opener (shared with `file-browse`). |
448
468
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer (shared with `file-browse`). |
@@ -26,12 +26,12 @@ import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui/chrom
26
26
  import { paint } from "../../lib/tui/tokens.ts";
27
27
  import { expandHint } from "../../lib/tui/glyphs.ts";
28
28
  import { READ_ONLY_SUMMARY } from "./policy.ts";
29
- import { feedbackSummary } from "./feedback.ts";
29
+ import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
30
30
  import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEPS_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
31
31
  import { PLAN_OUTLINE, extractPlanSteps, type PlanStep, type StepUpdate } from "./steps.ts";
32
32
  import { PlanViewComponent, sanitizePlanText, type PlanViewAction } from "./tui.ts";
33
33
  import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
34
- import { createPlanWebReview, type PlanWebReview } from "./web.ts";
34
+ import { createPlanWebReview, type PlanReviewResult, type PlanReviewSession, type PlanWebReview } from "./web.ts";
35
35
 
36
36
  const ExitPlanModeParams = Type.Object({
37
37
  plan_path: Type.String({
@@ -394,6 +394,8 @@ export function registerTools(
394
394
  }
395
395
 
396
396
  let outcome = await reviewPlan(ctx, file);
397
+ // Comments carried by a browser approval, reported alongside the approval.
398
+ let browserComments: PlanAnnotation[] = [];
397
399
  // Review, refine, and browser review all loop back here until the user
398
400
  // approves, keeps planning, or a refinement is handed to the model. The
399
401
  // dialog-based refine editor carries no text from the review screen, so an
@@ -401,15 +403,25 @@ export function registerTools(
401
403
  // as "not approved".
402
404
  for (;;) {
403
405
  if (outcome.action === "browser") {
404
- const session = await web.start(ctx.cwd, file).catch(() => undefined);
406
+ const session: PlanReviewSession | undefined = await web
407
+ .start(ctx.cwd, file, { canApprove: true })
408
+ .catch(() => undefined);
405
409
  if (!session) {
406
410
  ctx.ui.notify("Plan review: could not start the browser review server.", "error");
407
411
  outcome = await reviewPlan(ctx, file);
408
412
  continue;
409
413
  }
410
414
  ctx.ui.notify(`Plan review: opened ${session.url}`, "info");
411
- const result = await session.done;
415
+ const result: PlanReviewResult = await session.done;
412
416
  await session.close();
417
+ if (result.status === "approved") {
418
+ // Approve from the browser is the TUI's approve: keep the comments it
419
+ // carried and fall through to the shared approval path below.
420
+ file = result.plan;
421
+ browserComments = result.annotations;
422
+ outcome = { action: "approve" };
423
+ continue;
424
+ }
413
425
  if (result.status === "saved") {
414
426
  // The comments are already in the file; hand them back as a refinement so
415
427
  // the model revises the same plan and calls exit_plan_mode again.
@@ -491,11 +503,14 @@ export function registerTools(
491
503
  : steps.length > 0
492
504
  ? ` Could not record the steps in the todo list${error ? `: ${error}` : ""}. Keep these steps in mind and tick them off with ${SET_PLAN_STEPS_TOOL} as you go.`
493
505
  : " If the todo tool is available, record the steps with it before you start.";
506
+ const browserNote = browserComments.length
507
+ ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(browserComments)}`
508
+ : "";
494
509
  return {
495
510
  content: [
496
511
  {
497
512
  type: "text",
498
- text: `Plan approved. Plan mode is off and write access is restored. The plan file ${file.relativePath} is the source of truth; follow its steps.${tail}${listing}`,
513
+ text: `Plan approved. Plan mode is off and write access is restored. The plan file ${file.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
499
514
  },
500
515
  ],
501
516
  details: {