@gavin-hu/my-pi-agent 0.16.0 → 0.17.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 (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The Markdown half of the review client script.
3
+ *
4
+ * It is a fragment of the same IIFE `web-client.ts` ships (`WEB_MARKDOWN_JS` is
5
+ * interpolated into `REVIEW_CLIENT_JS`), kept in its own module so the client's
6
+ * event wiring and its document rendering are read one at a time. Every value it
7
+ * writes is a DOM text node: plan and comment text never becomes markup, and the
8
+ * only HTML it copies is the server's own `tok-*` spans.
9
+ */
10
+
11
+ export const WEB_MARKDOWN_JS = String.raw` // A link is rendered only for an http(s) or mailto target. Resolving against
12
+ // the review origin is what catches a target that only looks relative: the
13
+ // URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
14
+ // it is parsed, and a disguised target must stay text.
15
+ function safeHref(url, base) {
16
+ var trimmed = String(url).trim();
17
+ if (!trimmed || /^\/\//.test(trimmed)) return null;
18
+ var resolved;
19
+ try { resolved = new URL(trimmed, base); } catch (error) { return null; }
20
+ if (resolved.protocol === "mailto:") return trimmed;
21
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
22
+ if (resolved.username || resolved.password) return null;
23
+ if (resolved.origin === new URL(base).origin) return resolved.href;
24
+ return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
25
+ }
26
+
27
+ // Inline token order matters: code, emphasis, and links are matched before a
28
+ // bare URL, so a URL inside a link target or a code span is never re-split.
29
+ var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
30
+
31
+ function renderInline(container, text) {
32
+ String(text).split(INLINE).forEach(function (part) {
33
+ if (!part) return;
34
+ var match;
35
+ if ((match = /^\x60([^\x60]+)\x60$/.exec(part))) {
36
+ var code = el("code"); code.textContent = match[1]; container.appendChild(code); return;
37
+ }
38
+ if ((match = /^\*\*([^*]+)\*\*$/.exec(part))) {
39
+ var strong = el("strong"); strong.textContent = match[1]; container.appendChild(strong); return;
40
+ }
41
+ if ((match = /^\*([^*]+)\*$/.exec(part))) {
42
+ var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
43
+ }
44
+ if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
45
+ var href = safeHref(match[2], location.href);
46
+ if (!href) { container.appendChild(document.createTextNode(part)); return; }
47
+ var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
48
+ container.appendChild(link); return;
49
+ }
50
+ // GFM extended autolink: a bare http(s) or www. URL in running text.
51
+ if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
52
+ var url = match[0].replace(/[.,;:!?*_~]+$/, "");
53
+ while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
54
+ url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
55
+ }
56
+ var dot = url.indexOf(".");
57
+ var target = /^www\./i.test(url) ? "http://" + url : url;
58
+ var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
59
+ if (safe) {
60
+ var auto = el("a");
61
+ auto.textContent = url;
62
+ auto.href = safe;
63
+ auto.target = "_blank";
64
+ auto.rel = "noreferrer noopener";
65
+ container.appendChild(auto);
66
+ return;
67
+ }
68
+ }
69
+ container.appendChild(document.createTextNode(part));
70
+ });
71
+ }
72
+
73
+ // Copy only text and tok-* spans out of server-highlighted HTML, so a code
74
+ // line can never introduce markup even though it arrives as a string.
75
+ function copyTokens(from, to) {
76
+ Array.prototype.forEach.call(from.childNodes, function (node) {
77
+ if (node.nodeType === 3) { to.appendChild(document.createTextNode(node.nodeValue)); return; }
78
+ if (node.nodeType === 1 && node.tagName === "SPAN" && /^tok-/.test(node.className || "")) {
79
+ var span = el("span", node.className);
80
+ copyTokens(node, span);
81
+ to.appendChild(span);
82
+ return;
83
+ }
84
+ to.appendChild(document.createTextNode(node.textContent || ""));
85
+ });
86
+ }
87
+
88
+ function codeBody(lines) {
89
+ var body = Array.isArray(lines) ? lines.slice() : [];
90
+ if (body.length && /^ {0,3}(\x60{3,}|~{3,})/.test(body[0])) body.shift();
91
+ if (body.length && /^ {0,3}(\x60{3,}|~{3,})/.test(body[body.length - 1])) body.pop();
92
+ return body;
93
+ }
94
+
95
+ function renderCode(wrap, block) {
96
+ var body = codeBody(block.lines);
97
+ var highlighted = data.code && data.code[String(block.start)];
98
+ var pre = el("pre", "bf-code");
99
+ var code = el("code");
100
+ if (Array.isArray(highlighted)) {
101
+ body.forEach(function (line, index) {
102
+ var span = el("span", "bf-code__line");
103
+ var html = highlighted[index];
104
+ if (html) {
105
+ try { copyTokens(new DOMParser().parseFromString(html, "text/html").body, span); }
106
+ catch (error) { span.textContent = line; }
107
+ } else {
108
+ span.textContent = line;
109
+ }
110
+ code.appendChild(span);
111
+ if (index < body.length - 1) code.appendChild(document.createTextNode("\n"));
112
+ });
113
+ } else {
114
+ code.textContent = body.join("\n");
115
+ }
116
+ pre.appendChild(code);
117
+ wrap.appendChild(pre);
118
+ }
119
+
120
+ // A GFM table: split each pipe row into cells and honour the alignment row.
121
+ function splitRow(line) {
122
+ var text = String(line).trim();
123
+ if (text.charAt(0) === "|") text = text.slice(1);
124
+ if (text.charAt(text.length - 1) === "|") text = text.slice(0, -1);
125
+ return text.split("|").map(function (cell) { return cell.trim(); });
126
+ }
127
+
128
+ function columnAlign(delim) {
129
+ return splitRow(delim).map(function (cell) {
130
+ var left = cell.charAt(0) === ":";
131
+ var right = cell.charAt(cell.length - 1) === ":";
132
+ return left && right ? "center" : right ? "right" : left ? "left" : "";
133
+ });
134
+ }
135
+
136
+ function renderTable(wrap, block) {
137
+ var rows = block.lines.map(splitRow);
138
+ var align = columnAlign(block.lines[1] || "");
139
+ var table = el("table", "review-table");
140
+ var thead = el("thead");
141
+ var headRow = el("tr");
142
+ rows[0].forEach(function (cell, index) {
143
+ var th = el("th");
144
+ if (align[index]) th.style.textAlign = align[index];
145
+ renderInline(th, cell);
146
+ headRow.appendChild(th);
147
+ });
148
+ thead.appendChild(headRow);
149
+ table.appendChild(thead);
150
+ var tbody = el("tbody");
151
+ rows.slice(2).forEach(function (cells) {
152
+ var tr = el("tr");
153
+ rows[0].forEach(function (unused, index) {
154
+ var td = el("td");
155
+ if (align[index]) td.style.textAlign = align[index];
156
+ renderInline(td, cells[index] || "");
157
+ tr.appendChild(td);
158
+ });
159
+ tbody.appendChild(tr);
160
+ });
161
+ table.appendChild(tbody);
162
+ var scroll = el("div", "review-table-wrap");
163
+ scroll.appendChild(table);
164
+ wrap.appendChild(scroll);
165
+ }
166
+
167
+ function renderBlockContent(wrap, block) {
168
+ var lines = Array.isArray(block.lines) ? block.lines : [];
169
+ if (block.kind === "heading") {
170
+ var text = lines.join(" ").replace(/^\s*#{1,6}\s*/, "");
171
+ var level = Math.min(6, Math.max(1, (lines[0].match(/^ {0,3}(#{1,6})/) || ["", "#"])[1].length));
172
+ var heading = el("h" + level);
173
+ renderInline(heading, text);
174
+ wrap.appendChild(heading);
175
+ return;
176
+ }
177
+ if (block.kind === "code") { renderCode(wrap, block); return; }
178
+ if (block.kind === "table") { renderTable(wrap, block); return; }
179
+ if (block.kind === "hr") { wrap.appendChild(el("hr")); return; }
180
+ if (block.kind === "quote") {
181
+ var quote = el("blockquote");
182
+ renderInline(quote, lines.map(function (line) { return line.replace(/^\s*>\s?/, ""); }).join("\n"));
183
+ wrap.appendChild(quote);
184
+ return;
185
+ }
186
+ if (block.kind === "list") {
187
+ var items = [];
188
+ lines.forEach(function (line) {
189
+ var match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
190
+ if (!match) {
191
+ // A following unmarked line is a CommonMark lazy continuation of the
192
+ // item above; join it so the paragraph hangs under the item's text.
193
+ var last = items[items.length - 1];
194
+ if (last) { if (line.trim()) last.rest += " " + line.trim(); return; }
195
+ var plain = el("p"); renderInline(plain, line); wrap.appendChild(plain); return;
196
+ }
197
+ var row = el("div", "bf-block__li");
198
+ row.style.paddingLeft = String(Math.floor(match[1].replace(/\t/g, " ").length / 2) * 16) + "px";
199
+ var markerText = match[2];
200
+ var rest = match[3];
201
+ var box = /^\[([ xX])\]\s*(.*)$/.exec(rest);
202
+ var marker = el("span", "marker");
203
+ if (box) {
204
+ var done = box[1].toLowerCase() === "x";
205
+ markerText = done ? "\u2713" : "\u2610";
206
+ marker.className = done ? "bf-block__marker bf-block__marker--done" : "bf-block__marker";
207
+ rest = box[2];
208
+ }
209
+ marker.textContent = markerText;
210
+ var body = el("span");
211
+ row.appendChild(marker); row.appendChild(body); wrap.appendChild(row);
212
+ items.push({ body: body, rest: rest });
213
+ });
214
+ // Render each body once, after its continuation lines are collected.
215
+ items.forEach(function (item) { renderInline(item.body, item.rest); });
216
+ return;
217
+ }
218
+ var paragraph = el("p");
219
+ renderInline(paragraph, lines.join(" "));
220
+ wrap.appendChild(paragraph);
221
+ }
222
+ `;
@@ -179,7 +179,16 @@ ${WEB_COMPONENTS_CSS}
179
179
  }
180
180
  @media (prefers-reduced-motion:no-preference){.bf-block{transition:background var(--motion-fast) var(--motion-ease)}}
181
181
  @media (hover:none){.bf-block__add{opacity:1}}
182
- `.trim();
182
+ /* On paper the document is the whole page: no comment rail to hover, no composer,
183
+ no reading progress, and the comments stay with the blocks they belong to. */
184
+ @media print{
185
+ .bf-review{padding:0}
186
+ .bf-review>.bf-block{max-width:none;padding:0 0 6px}
187
+ .bf-block__rail,.bf-composer,.bf-block__add,.review-progress{display:none}
188
+ .bf-block:hover,.bf-block--active{background:none;box-shadow:none}
189
+ .bf-comments{padding-left:0}
190
+ .bf-comment{break-inside:avoid}
191
+ }`.trim();
183
192
 
184
193
  /**
185
194
  * The sidebar's one-line keyboard reminder. It names the keys a reviewer reaches
@@ -206,6 +215,22 @@ function reviewKeys(canApprove: boolean): KeyHint[] {
206
215
  return rows;
207
216
  }
208
217
 
218
+ /**
219
+ * The approval confirmation. Approving leaves plan mode and runs the plan, which
220
+ * cannot be undone from the page, so it is one deliberate dialog rather than the
221
+ * header button's single click. The shared `bf-dialog` styles and the shared
222
+ * dialog runtime supply the backdrop, the Tab trap, and `Esc`.
223
+ */
224
+ function approveDialog(): string {
225
+ return `<div class="bf-dialog" id="approve-dialog" hidden>
226
+ <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="approve-title">
227
+ <div class="bf-dialog__head"><h2 id="approve-title">Approve and run the plan?</h2></div>
228
+ <div class="bf-dialog__body"><p>Pi leaves plan mode and starts the work. The comments you added are saved with the plan.</p><p class="bf-dialog__hint">Choose <strong>Approve &amp; run</strong> to go ahead, or <strong>Cancel</strong> to keep reviewing.</p></div>
229
+ <div class="bf-dialog__actions"><button type="button" class="bf-btn" id="approve-cancel">Cancel</button><button type="button" class="bf-btn bf-btn--primary" id="approve-confirm">Approve &amp; run</button></div>
230
+ </div>
231
+ </div>`;
232
+ }
233
+
209
234
  /**
210
235
  * Render the review page for `data`. The markup is a fixed shell; the client
211
236
  * fills every dynamic field, so this function is the only place that needs the
@@ -258,6 +283,7 @@ ${saveButton}
258
283
  ${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
259
284
  </div>
260
285
  ${keysSheet(reviewKeys(data.canApprove))}
286
+ ${data.canApprove ? approveDialog() : ""}
261
287
  <script type="application/json" id="review-data">${island}</script>
262
288
  <script src="/client.js" defer></script>
263
289
  </body>
@@ -271,7 +297,7 @@ ${keysSheet(reviewKeys(data.canApprove))}
271
297
  */
272
298
  function reviewFacts(): string {
273
299
  return `<div class="review-facts" id="review-facts">
274
- <div class="review-fact"><span>Steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
300
+ <div class="review-fact" id="step-fact"><span>Steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
275
301
  <div class="review-fact"><span>Comments</span><span class="review-fact__value" id="comment-total">0</span></div>
276
302
  </div>`;
277
303
  }
@@ -23,7 +23,7 @@ import {
23
23
  scriptHeaders,
24
24
  type ServeResponse,
25
25
  } from "../../lib/http/index.ts";
26
- import { THEME_JS } from "../../lib/web/index.ts";
26
+ import { THEME_JS, THEME_JS_PATH } from "../../lib/web/index.ts";
27
27
  import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
28
28
  import { REVIEW_CLIENT_JS } from "./web-client.ts";
29
29
  import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
@@ -109,7 +109,7 @@ export async function handleReviewRequest(
109
109
  const { pathname, search } = parsed;
110
110
 
111
111
  if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
112
- if (pathname === "/theme.js") return { status: 200, headers: scriptHeaders(), body: THEME_JS };
112
+ if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
113
113
  if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
114
114
 
115
115
  if (search.get("token") !== ctx.token) return jsonError(403, "Invalid or missing token.");
@@ -152,6 +152,8 @@ export abstract class ChecklistScreen<T> implements Component {
152
152
  const rows = this.rows();
153
153
  if (rows.length === 0 && this.options.emptyLabel !== undefined) {
154
154
  return dockListFrame({
155
+ theme: this.theme,
156
+ width: w,
155
157
  header,
156
158
  body: emptyState(this.theme, w, { label: this.options.emptyLabel }),
157
159
  hint: this.hint(w),
@@ -172,6 +174,8 @@ export abstract class ChecklistScreen<T> implements Component {
172
174
  }
173
175
 
174
176
  return dockListFrame({
177
+ theme: this.theme,
178
+ width: w,
175
179
  header,
176
180
  summary: clip(` ${paint(this.theme, "meta", this.summaryText())}`, w),
177
181
  body,
@@ -28,7 +28,7 @@ import { SELECTION_MARKS, type PathChange, type ReviewEntry } from "./paths.ts";
28
28
 
29
29
  /** Rows a diff screen shows when the terminal height is unknown. */
30
30
  const DIFF_DEFAULT_ITEMS = 20;
31
- /** Header, summary, blank, blank, hint, and blank rows around the diff. */
31
+ /** Header, summary, blank, blank, hint, and closing-rule rows around the diff. */
32
32
  const DIFF_CHROME_ROWS = 6;
33
33
 
34
34
  /** Lines one wheel notch moves in the diff pane. */
@@ -294,6 +294,8 @@ export class ReviewDiffComponent implements Component {
294
294
  if (body.length === 0) body.push(...emptyState(this.theme, w, { label: "No textual diff." }));
295
295
 
296
296
  return dockListFrame({
297
+ theme: this.theme,
298
+ width: w,
297
299
  header,
298
300
  summary,
299
301
  body,
@@ -79,6 +79,8 @@ export class RewindListComponent implements Component {
79
79
 
80
80
  if (this.points.length === 0) {
81
81
  return dockListFrame({
82
+ theme: this.theme,
83
+ width: w,
82
84
  header,
83
85
  body: emptyState(this.theme, w, { label: "No prompts on this branch yet." }),
84
86
  hint: this.hint(w),
@@ -122,6 +124,8 @@ export class RewindListComponent implements Component {
122
124
  }
123
125
 
124
126
  return dockListFrame({
127
+ theme: this.theme,
128
+ width: w,
125
129
  header,
126
130
  summary: clip(` ${paint(this.theme, "meta", `${count}${SEPARATORS.item}newest first`)}`, w),
127
131
  body,
@@ -275,9 +275,18 @@ child's system prompt for behavior; the tool list is the enforcement.
275
275
  → grep /retry/ in ~/src
276
276
  → read ~/src/http.ts:40-120
277
277
  Retries are configured in http.ts:64...
278
- 2 turns ↑12.4k ↓1.1k R8.0k $0.0042 ctx:18.2k claude-sonnet-4-5
278
+
279
+ 2 turns ↑12.4k ↓1.1k R8.0k ctx:18.2k claude-sonnet-4-5
279
280
  ```
280
281
 
282
+ The footnote is the same in every mode: a blank line, the faint usage row, then —
283
+ when the run has more to show — the expand affordance, so the hint is always the
284
+ block's last line. The row carries turns, token counts, context size, and the
285
+ model — never money. A price needs a currency and a tariff, and those belong to
286
+ the status bar, which shows every child run as its own `↳` roll-up priced in the
287
+ configured currency (see [Non-goals](#non-goals)). A parallel or chain `Total:`
288
+ row sums the counters, and names a model only when every task ran the same one.
289
+
281
290
  **Expanded** (the `app.tools.expand` key, `ctrl+o` by default): the full task,
282
291
  every intermediate assistant message and tool call, the final output rendered as
283
292
  Markdown, and per-step/per-task usage with a total.
@@ -296,7 +305,9 @@ the first failing step also reports `stopped at step N`. Running tasks show an
296
305
  elapsed-time label on the shared 1s repaint timer (the package's common
297
306
  duration format, e.g. `12.0s`), and a finished run keeps its duration. A result
298
307
  that hit tool errors appends `N tool errors` to its
299
- usage line (and to the multi `Total:`).
308
+ usage line (and to the multi `Total:`). A usage payload the child reports with a
309
+ non-number (a string, `NaN`) contributes nothing, so a malformed event cannot
310
+ corrupt the row.
300
311
 
301
312
  A chain step's expanded `Task` section shows the step's original instruction,
302
313
  with `{previous}` left in place, rather than the substituted prior output that
@@ -317,15 +328,17 @@ step's `Output`.
317
328
  details; keeping it on disk is opt-in, parent-written, and bounded by age and
318
329
  count. It is an audit and handoff record, not a resume point: replaying a run
319
330
  whose writes already landed is not attempted.
320
- - **No cost attribution to the parent session.** A subagent's tokens and cost are
331
+ - **No cost attribution to the parent session.** A subagent's token counts are
321
332
  captured in the tool result and shown in the transcript, but they belong to a
322
- separate process and are not added to the dispatching session's totals. Instead,
323
- each finished run publishes `my-pi-agent/subagent-finished`
324
- (`SubagentFinishedEvent` in `lib/events.ts`) and bytefly's footer shows the
325
- session's child runs as their own `↳` roll-up, priced separately from the
326
- session total (the last 500 runs). The roll-up is per process: a resumed session
327
- starts from zero, and a run that never produced an assistant message publishes
328
- nothing.
333
+ separate process and are not added to the dispatching session's totals. The
334
+ child's own dollar cost stays in the tool details (and in a run's `meta.json`)
335
+ and is never printed in the transcript: only the status bar prices a delegated
336
+ run, in the configured currency and tariff. Each finished run publishes
337
+ `my-pi-agent/subagent-finished` (`SubagentFinishedEvent` in `lib/events.ts`)
338
+ and bytefly's footer shows the session's child runs as their own `↳` roll-up,
339
+ priced separately from the session total (the last 500 runs). The roll-up is
340
+ per process: a resumed session starts from zero, and a run that never produced
341
+ an assistant message publishes nothing.
329
342
 
330
343
  ## Limitations
331
344
 
@@ -421,7 +434,7 @@ step's `Output`.
421
434
  | `agents.ts` | Built-in agent definitions, external markdown discovery, and pool resolution. |
422
435
  | `schema.ts` | TypeBox params, limits, and `resolveMode`. |
423
436
  | `invocation.ts` | `pi` subprocess invocation and argument construction. |
424
- | `stream.ts` | JSON-event parsing, usage accounting, output helpers (pure). |
437
+ | `stream.ts` | JSON-event parsing, guarded usage accounting, output helpers (pure). |
425
438
  | `transcripts.ts` | Per-run transcript buffer, run ids, the store directory, and pruning. |
426
439
  | `config.ts` | The `subagent` section of `bytefly.json`: deadline, transcripts, retention. |
427
440
  | `run.ts` | Runs one subagent process and captures its result (including the background abort-as-result path). |
@@ -429,6 +442,6 @@ step's `Output`.
429
442
  | `background-tui.ts` | The `/agents` board: a `DockListScreen` with a live output pane and width-aware facts. |
430
443
  | `commands.ts` | The `/agents` command. |
431
444
  | `ui.ts` | The running-count footer chip and finish notices. |
432
- | `format.ts` | Token/usage/tool-call formatting (pure). |
445
+ | `format.ts` | Token/usage/total line and tool-call formatting (pure, money-free). |
433
446
  | `render.ts` | `renderCall` / `renderResult` transcript rendering. |
434
447
  | `types.ts` | Shared result and process types. |
@@ -1,46 +1,93 @@
1
1
  /**
2
2
  * Pure formatting helpers shared by the transcript renderers.
3
+ *
4
+ * The usage line keeps tokens, turns, model, and context — the facts the child
5
+ * reported — and never money: a price needs a currency and a tariff, which the
6
+ * bytefly footer owns for the whole session. See `usageLine` / `totalUsageLine`.
3
7
  */
4
8
 
5
9
  import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
7
11
  import { formatDuration, formatTokens, sanitizeLine, shortenPath } from "../../lib/format.ts";
12
+ import { USAGE_COUNTERS, zeroUsage } from "../../lib/usage.ts";
8
13
  import type { BackgroundRun } from "./background.ts";
9
- import type { UsageStats } from "./types.ts";
14
+ import type { UsageSource, UsageStats } from "./types.ts";
10
15
 
11
- /** Dollar cost, keeping four decimals for the sub-cent amounts these runs produce. */
12
- function formatCost(cost: number): string {
13
- if (cost >= 1) return `$${cost.toFixed(2)}`;
14
- if (cost >= 0.0001) return `$${cost.toFixed(4)}`;
15
- return `$${cost.toPrecision(2)}`;
16
+ /** `2 turns` / `1 turn`, or "" when no turn was counted. */
17
+ function turnPart(turns: number | undefined): string {
18
+ if (!turns) return "";
19
+ return `${turns} turn${turns > 1 ? "s" : ""}`;
20
+ }
21
+
22
+ /** A prefix and a compact count, or "" when the counter is zero. */
23
+ function tokenPart(prefix: string, count: number | undefined): string {
24
+ return count ? `${prefix}${formatTokens(count)}` : "";
25
+ }
26
+
27
+ /** `ctx:18.2k`, or "" when no context was reported. */
28
+ function contextPart(tokens: number | undefined): string {
29
+ const count = tokens ?? 0;
30
+ return count > 0 ? `ctx:${formatTokens(count)}` : "";
16
31
  }
17
32
 
18
33
  /** A space-separated usage line, omitting zero fields. */
19
34
  export function formatUsageStats(usage: Partial<UsageStats>, model?: string): string {
20
- const parts: string[] = [];
21
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
22
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
23
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
24
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
25
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
26
- if (usage.cost) parts.push(formatCost(usage.cost));
27
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
28
- if (model) parts.push(model);
29
- return parts.join(" ");
35
+ return [
36
+ turnPart(usage.turns),
37
+ tokenPart("↑", usage.input),
38
+ tokenPart("↓", usage.output),
39
+ tokenPart("R", usage.cacheRead),
40
+ tokenPart("W", usage.cacheWrite),
41
+ contextPart(usage.contextTokens),
42
+ model ?? "",
43
+ ]
44
+ .filter(Boolean)
45
+ .join(" ");
30
46
  }
31
47
 
32
48
  /** Sum usage across results, for the chain/parallel totals line. */
33
- export function aggregateUsage(results: { usage: UsageStats }[]): Partial<UsageStats> {
34
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
49
+ export function aggregateUsage(results: readonly { usage: UsageStats }[]): Partial<UsageStats> {
50
+ const counters = zeroUsage();
51
+ let turns = 0;
35
52
  for (const { usage } of results) {
36
- total.input += usage.input;
37
- total.output += usage.output;
38
- total.cacheRead += usage.cacheRead;
39
- total.cacheWrite += usage.cacheWrite;
40
- total.cost += usage.cost;
41
- total.turns += usage.turns;
53
+ for (const counter of USAGE_COUNTERS) counters[counter] += usage[counter];
54
+ turns += usage.turns;
42
55
  }
43
- return total;
56
+ return { ...counters, turns };
57
+ }
58
+
59
+ /** "2 tool errors", "1 tool error", or "" when the run hit none. */
60
+ function toolErrorsLabel(count: number | undefined): string {
61
+ if (!count) return "";
62
+ return `${count} tool error${count > 1 ? "s" : ""}`;
63
+ }
64
+
65
+ /**
66
+ * The one model every result ran, or undefined when they differ.
67
+ *
68
+ * A total may name a model only when the name is true of all of it: a batch whose
69
+ * tasks pinned different models gets no label rather than one task's.
70
+ */
71
+ function sharedModel(sources: readonly UsageSource[]): string | undefined {
72
+ const models = new Set(sources.map((source) => source.model).filter((model): model is string => Boolean(model)));
73
+ return models.size === 1 ? [...models][0] : undefined;
74
+ }
75
+
76
+ /** One result's usage line, with its tool-error count when it hit any. */
77
+ export function usageLine(source: UsageSource): string {
78
+ return [formatUsageStats(source.usage, source.model), toolErrorsLabel(source.toolErrors)].filter(Boolean).join(" ");
79
+ }
80
+
81
+ /**
82
+ * The `Total:` line for a multi run: summed counters, the shared model when there
83
+ * is one, and summed tool errors. A total carries no `ctx:` — every child has its
84
+ * own context window, so a sum of them would mean nothing.
85
+ */
86
+ export function totalUsageLine(sources: readonly UsageSource[]): string {
87
+ const usage = formatUsageStats(aggregateUsage(sources), sharedModel(sources));
88
+ const errors = sources.reduce((sum, source) => sum + (source.toolErrors ?? 0), 0);
89
+ const line = [usage, toolErrorsLabel(errors)].filter(Boolean).join(" ");
90
+ return line ? `Total: ${line}` : "";
44
91
  }
45
92
 
46
93
  /** Display-width-aware truncation with a trailing ellipsis (keeps wide chars/surrogates intact). */