@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
@@ -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,21 +51,28 @@ 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, tagged `peer` when another session owns it. `statusRow` owns
55
+ * the clipping and the alignment. */
56
+ function jobRow(
57
+ job: JobRecord,
58
+ theme: Theme,
59
+ width: number,
60
+ options: Pick<StatusRow, "selected" | "marker"> & { foreign?: boolean } = {},
61
+ ): string {
62
+ const elapsed = formatDuration(elapsedMs(job, Date.now()));
63
+ return statusRow(theme, width, {
64
+ ...options,
58
65
  glyph: statusGlyphChar(job.status),
59
66
  glyphRole: statusRole(job.status),
60
67
  label: `${job.id} ${shortLabel(job)}`,
61
- trailing: formatDuration(elapsedMs(job, Date.now())),
62
- trailingRole: "faint",
68
+ // The tag rides the right-aligned cell, which survives a narrow terminal.
69
+ trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
63
70
  });
64
71
  }
65
72
 
66
73
  /** Input for the transcript result: a themed job list or a log tail. */
67
74
  export type JobResultInput =
68
- | { kind: "list"; jobs: JobRecord[]; more: number }
75
+ | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
69
76
  | { kind: "logs"; lines: string[]; earlier: number };
70
77
 
71
78
  /**
@@ -95,7 +102,10 @@ export class JobResult implements Component {
95
102
  // A leading blank line separates the result from the call header.
96
103
  const lines: string[] = [""];
97
104
  if (this.input.kind === "list") {
98
- for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w));
105
+ for (const job of this.input.jobs) {
106
+ const foreign = this.input.sessionId !== undefined && job.sessionId !== this.input.sessionId;
107
+ lines.push(jobRow(job, this.theme, w, { marker: false, foreign }));
108
+ }
99
109
  if (this.input.more > 0) {
100
110
  const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
101
111
  lines.push(clip(` ${note}`, w));
@@ -114,6 +124,8 @@ export class JobResult implements Component {
114
124
  }
115
125
 
116
126
  export interface JobListCallbacks {
127
+ /** This session's id: a record with another id belongs to a peer session. */
128
+ sessionId(): string;
117
129
  /** Current sanitized log lines for a job, or undefined when unknown. */
118
130
  logs(id: string): { lines: string[]; more?: boolean } | undefined;
119
131
  kill(id: string): void;
@@ -171,6 +183,11 @@ export class JobListComponent implements Component {
171
183
  return [...this.jobs()].sort(compareJobs);
172
184
  }
173
185
 
186
+ /** Whether another session owns this job, so the screen leaves it alone. */
187
+ private isForeign(job: JobRecord): boolean {
188
+ return job.sessionId !== this.callbacks.sessionId();
189
+ }
190
+
174
191
  /** Focus job `next` (clamped) and keep it visible. */
175
192
  private select(next: number): void {
176
193
  const jobs = this.ordered;
@@ -252,7 +269,10 @@ export class JobListComponent implements Component {
252
269
  else if (data === "d" || data === "K") {
253
270
  const job = ordered[index];
254
271
  if (!job) return;
255
- if (job.status === "running") {
272
+ if (this.isForeign(job)) {
273
+ // Another session's job is shown here, not managed from here.
274
+ this.notice = `${job.id} is another session's job; not killed.`;
275
+ } else if (job.status === "running") {
256
276
  this.confirm = { kind: "kill", id: job.id, label: shortLabel(job) };
257
277
  } else {
258
278
  // A finished job has nothing to kill; say so instead of a silent no-op.
@@ -260,7 +280,9 @@ export class JobListComponent implements Component {
260
280
  }
261
281
  this.requestRender();
262
282
  } else if (data === "x") {
263
- const finished = ordered.filter((job) => job.status !== "running");
283
+ // Only this session's finished jobs are ours to clear, so the prompt and the
284
+ // result agree.
285
+ const finished = ordered.filter((job) => !this.isForeign(job) && job.status !== "running");
264
286
  if (finished.length > 0) {
265
287
  this.confirm = {
266
288
  kind: "clear",
@@ -316,10 +338,11 @@ export class JobListComponent implements Component {
316
338
  if (this.pane.handleKey(data)) this.requestRender();
317
339
  }
318
340
 
319
- private summary(counts: JobCounts, width: number): string {
341
+ private summary(counts: JobCounts, width: number, foreign: number): string {
320
342
  const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
321
343
  if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
322
344
  if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
345
+ if (foreign > 0) cells.push(paint(this.theme, "faint", `${foreign} peer`));
323
346
  const separator = paint(this.theme, "meta", SEPARATORS.item);
324
347
  return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
325
348
  }
@@ -334,7 +357,7 @@ export class JobListComponent implements Component {
334
357
  this.cursor.clamp(jobs, jobId);
335
358
  const index = this.cursor.indexOf(jobs, jobId);
336
359
  const focused = jobs[index];
337
- const detail = focused ? formatJobDetail(focused, Date.now()) : [];
360
+ const detail = focused ? formatJobDetail(focused, Date.now(), this.callbacks.sessionId()) : [];
338
361
  this.cursor.visible = fitRows(
339
362
  this.viewportRowsSource,
340
363
  jobs.length,
@@ -350,7 +373,10 @@ export class JobListComponent implements Component {
350
373
  body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
351
374
  } else {
352
375
  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));
376
+ for (let i = this.cursor.scroll; i < end; i++) {
377
+ const row = jobs[i];
378
+ body.push(jobRow(row, this.theme, w, { selected: i === index, foreign: this.isForeign(row) }));
379
+ }
354
380
  if (this.cursor.scroll > 0 || end < jobs.length) {
355
381
  range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
356
382
  }
@@ -371,7 +397,7 @@ export class JobListComponent implements Component {
371
397
 
372
398
  return dockListFrame({
373
399
  header: titleRule(this.theme, w, "Jobs"),
374
- summary: this.summary(jobCounts(jobs), w),
400
+ summary: this.summary(jobCounts(jobs), w, jobs.filter((job) => this.isForeign(job)).length),
375
401
  body,
376
402
  range,
377
403
  // The detail pane is separated from the list by a blank line.
@@ -10,7 +10,7 @@ export const JOB_STATUSES = ["running", "exited", "killed", "failed", "unknown"]
10
10
 
11
11
  export type JobStatus = (typeof JOB_STATUSES)[number];
12
12
 
13
- export const JOB_ACTIONS = ["start", "list", "status", "logs", "kill", "wait", "clear"] as const;
13
+ export const JOB_ACTIONS = ["start", "list", "status", "logs", "send", "kill", "wait", "clear"] as const;
14
14
 
15
15
  export type JobAction = (typeof JOB_ACTIONS)[number];
16
16
 
@@ -40,6 +40,8 @@ export interface JobRecord {
40
40
  detached: boolean;
41
41
  /** Wake the agent with one triggered turn when the job finishes. */
42
42
  wake: boolean;
43
+ /** Started with a stdin pipe, so `send` can write to it while this session holds the child. */
44
+ stdin: boolean;
43
45
  /** Session that started the job. */
44
46
  sessionId: string;
45
47
  /** Whether the completion has already been reported to the model. */
@@ -83,10 +85,16 @@ export interface JobDetails {
83
85
  signalled?: boolean;
84
86
  /** Signal sent by `kill`; defaults to SIGTERM. */
85
87
  signal?: KillSignal;
88
+ /** Characters of `input` written by `send`, excluding the added newline. */
89
+ sent?: number;
90
+ /** True when `send` ended the job's stdin (EOF). */
91
+ stdinClosed?: boolean;
86
92
  /** True when `wait` hit its timeout. */
87
93
  timedOut?: boolean;
88
94
  /** True when `wait` was cancelled by the tool's abort signal. */
89
95
  cancelled?: boolean;
96
+ /** Session that made the call, so a transcript can mark another session's rows. */
97
+ sessionId?: string;
90
98
  /** Model-readable validation message when the call was rejected. */
91
99
  error?: string;
92
100
  }
@@ -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: {
@@ -25,6 +25,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
25
25
  var token = typeof data.token === "string" ? data.token : "";
26
26
  var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
27
27
  var saved = false;
28
+ var approved = false;
28
29
  var closed = false;
29
30
  var connected = null;
30
31
  var everConnected = false;
@@ -37,6 +38,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
37
38
  var planEl = document.getElementById("plan");
38
39
  var statusEl = document.getElementById("status");
39
40
  var stateEl = document.getElementById("review-state");
41
+ var approveButton = document.getElementById("approve");
40
42
  var saveButton = document.getElementById("save");
41
43
  var cancelButton = document.getElementById("cancel");
42
44
  var outlineList = document.getElementById("outline-list");
@@ -62,9 +64,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
62
64
  if (!stateEl) return;
63
65
  var label;
64
66
  var tone = "";
65
- if (saved) {
67
+ if (approved) {
68
+ label = "Approved";
69
+ tone = "success";
70
+ } else if (saved) {
66
71
  label = "Saved";
67
72
  tone = "success";
73
+ } else if (closed) {
74
+ // Settled by the user, not by a dropped stream: never flash Offline.
75
+ label = "Closed";
68
76
  } else if (connected === true) {
69
77
  label = "Connected";
70
78
  tone = "success";
@@ -78,7 +86,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
78
86
  }
79
87
  var count = annotations.length;
80
88
  label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
81
- if (dirty && !saved) {
89
+ if (dirty && !saved && !approved && !closed) {
82
90
  label += " \u00b7 unsaved";
83
91
  if (!tone) tone = "warn";
84
92
  }
@@ -254,9 +262,14 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
254
262
  return;
255
263
  }
256
264
  if (block.kind === "list") {
265
+ var items = [];
257
266
  lines.forEach(function (line) {
258
267
  var match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
259
268
  if (!match) {
269
+ // A following unmarked line is a CommonMark lazy continuation of the
270
+ // item above; join it so the paragraph hangs under the item's text.
271
+ var last = items[items.length - 1];
272
+ if (last) { if (line.trim()) last.rest += " " + line.trim(); return; }
260
273
  var plain = el("p"); renderInline(plain, line); wrap.appendChild(plain); return;
261
274
  }
262
275
  var row = el("div", "bf-block__li");
@@ -272,9 +285,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
272
285
  rest = box[2];
273
286
  }
274
287
  marker.textContent = markerText;
275
- var body = el("span"); renderInline(body, rest);
288
+ var body = el("span");
276
289
  row.appendChild(marker); row.appendChild(body); wrap.appendChild(row);
290
+ items.push({ body: body, rest: rest });
277
291
  });
292
+ // Render each body once, after its continuation lines are collected.
293
+ items.forEach(function (item) { renderInline(item.body, item.rest); });
278
294
  return;
279
295
  }
280
296
  var paragraph = el("p");
@@ -600,6 +616,7 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
600
616
  }
601
617
 
602
618
  function lockActions() {
619
+ if (approveButton) approveButton.disabled = true;
603
620
  if (saveButton) saveButton.disabled = true;
604
621
  if (cancelButton) cancelButton.disabled = true;
605
622
  }
@@ -618,51 +635,67 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
618
635
  controls.appendChild(reload);
619
636
  }
620
637
 
621
- function save() {
622
- if (saved) return;
623
- setStatus("Saving\u2026");
624
- fetch("/api/feedback?token=" + encodeURIComponent(token), {
638
+ // Save and approve share the same POST shape; only the route, the settled
639
+ // flag, and the wording differ. Both persist the current annotations.
640
+ function submit(url, okText, failText, mark) {
641
+ fetch(url + "?token=" + encodeURIComponent(token), {
625
642
  method: "POST",
626
643
  headers: { "Content-Type": "application/json" },
627
644
  body: JSON.stringify({ annotations: annotations })
628
645
  }).then(function (response) {
629
646
  if (response.ok) {
630
- saved = true;
647
+ mark();
631
648
  dirty = false;
632
- setStatus("Saved \u2014 return to Pi to continue.", "saved");
649
+ setStatus(okText, "saved");
633
650
  lockActions();
634
651
  updateState();
635
652
  return;
636
653
  }
637
654
  if (response.status === 409) {
638
655
  setStatus("The plan changed on disk \u2014 reload to review again.", "error");
656
+ if (approveButton) approveButton.disabled = true;
639
657
  if (saveButton) saveButton.disabled = true;
640
658
  offerReload();
641
659
  return;
642
660
  }
643
661
  throw new Error("server returned " + response.status);
644
662
  }).catch(function (error) {
645
- setStatus("Could not save: " + error.message, "error");
663
+ setStatus(failText + error.message, "error");
646
664
  });
647
665
  }
648
666
 
667
+ function save() {
668
+ if (saved || approved || closed) return;
669
+ setStatus("Saving\u2026");
670
+ submit("/api/feedback", "Saved \u2014 return to Pi to continue.", "Could not save: ", function () { saved = true; });
671
+ }
672
+
673
+ function approve() {
674
+ if (saved || approved || closed) return;
675
+ setStatus("Approving\u2026");
676
+ submit("/api/approve", "Approved \u2014 return to Pi to continue.", "Could not approve: ", function () { approved = true; });
677
+ }
678
+
649
679
  function cancel() {
650
- if (saved || closed) return;
680
+ if (saved || approved || closed) return;
651
681
  closed = true;
652
682
  lockActions();
653
683
  setStatus("Closing\u2026");
684
+ updateState();
654
685
  fetch("/api/cancel?token=" + encodeURIComponent(token), { method: "POST" }).catch(function () {}).then(function () {
655
- setStatus("Closed without saving.");
686
+ setStatus("Closed \u2014 return to Pi.");
687
+ updateState();
656
688
  });
657
689
  }
658
690
 
691
+ if (approveButton) approveButton.addEventListener("click", approve);
659
692
  if (saveButton) saveButton.addEventListener("click", save);
660
693
  if (cancelButton) cancelButton.addEventListener("click", cancel);
661
694
 
662
695
  // Remember a selection made inside a block so clicking the rail button can
663
696
  // still quote it after the button press has collapsed the selection.
664
697
  document.addEventListener("mouseup", function (event) {
665
- if (!planEl || saved) return;
698
+ if (!planEl || saved || approved || closed) return;
666
699
  var wrap = event.target && event.target.closest ? event.target.closest(".bf-block") : null;
667
700
  var quote = wrap && !wrap.querySelector(".bf-composer") ? selectionIn(wrap) : "";
668
701
  pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
@@ -692,7 +725,13 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
692
725
  }
693
726
 
694
727
  document.addEventListener("keydown", function (event) {
695
- if (event.key === "Escape") { closeComposer(); toggleKeys(false); return; }
728
+ if (event.key === "Escape") {
729
+ // Esc peels one layer at a time: the composer, then the sheet, then the review.
730
+ if (planEl && planEl.querySelector(".bf-composer")) { closeComposer(); return; }
731
+ if (keysDialog && !keysDialog.hidden) { toggleKeys(false); return; }
732
+ cancel();
733
+ return;
734
+ }
696
735
  if (typing(event.target)) return;
697
736
  if (event.metaKey || event.ctrlKey || event.altKey) return;
698
737
  if (event.key === "?") { event.preventDefault(); toggleKeys(); return; }
@@ -701,11 +740,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
701
740
  if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
702
741
  if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
703
742
  if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
743
+ if (event.key === "a" && approveButton) { event.preventDefault(); approve(); return; }
704
744
  if (event.key === "s") { event.preventDefault(); save(); return; }
705
745
  });
706
746
 
707
747
  window.addEventListener("beforeunload", function () {
708
- if (saved || closed) return;
748
+ if (saved || approved || closed) return;
709
749
  var url = "/api/cancel?token=" + encodeURIComponent(token);
710
750
  if (navigator.sendBeacon) navigator.sendBeacon(url);
711
751
  });
@@ -718,8 +758,9 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
718
758
  var stream = new EventSource("/events?token=" + encodeURIComponent(token));
719
759
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
720
760
  stream.addEventListener("error", function () {
721
- // The server ends the stream on save; keep showing Saved rather than Offline.
722
- if (!saved) { connected = false; updateState(); }
761
+ // The server ends the stream once the review is settled; keep showing the
762
+ // terminal state rather than Offline.
763
+ if (!saved && !approved && !closed) { connected = false; updateState(); }
723
764
  });
724
765
  } catch (error) {
725
766
  // The server still sees the POST; the stream is only for close detection.