@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -5,13 +5,14 @@
5
5
  * matching rates and the timezone formatter. Both currencies price a record
6
6
  * from `rates` and `peak` when a key matches. They differ only for a model no
7
7
  * key matches: `USD` adds Pi's recorded dollar amount (its catalog is already
8
- * per model), while `CNY` adds nothing and counts the record as unpriced, so the
9
- * session can say what its total leaves out.
8
+ * per model), while `CNY` adds nothing. A record neither of them prices is
9
+ * unpriced, and it is counted by *model* rather than by record: the count says
10
+ * the total is short, and the key says how to fix it.
10
11
  */
11
12
 
12
13
  import { zeroUsage } from "../../../../lib/usage.ts";
13
14
  import type { ByteflyConfig, ModelRates } from "./types.ts";
14
- import type { PricedUsage, UsageRecord } from "../types.ts";
15
+ import type { PricedUsage, UnpricedModel, UsageRecord } from "../types.ts";
15
16
  import { createRateLookup } from "./rates.ts";
16
17
  import { isPeak, peakFormatter } from "./schedule.ts";
17
18
 
@@ -44,7 +45,10 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
44
45
  const format = config.peak.windows.length > 0 ? peakFormatter(config.peak.timezone) : null;
45
46
 
46
47
  return (records) => {
47
- const totals: PricedUsage = { ...zeroUsage(), unpriced: 0 };
48
+ const totals: PricedUsage = { ...zeroUsage(), unpriced: 0, unpricedModels: [] };
49
+ // One tally per model key, in first-seen order, so a report reads in the order the
50
+ // session met the models rather than in hash order.
51
+ const unpriced = new Map<string, number>();
48
52
  for (const record of records) {
49
53
  totals.input += record.input;
50
54
  totals.output += record.output;
@@ -55,15 +59,33 @@ export function createPricer(config: ByteflyConfig): (records: readonly UsageRec
55
59
  totals.cost += ratedCost(config, format, record, rates);
56
60
  continue;
57
61
  }
58
- // No rate: USD still has Pi's own per-model amount, CNY has nothing to add
59
- // and only a count to report.
60
- if (dollars) totals.cost += record.recordedCost;
61
- else totals.unpriced += 1;
62
+ if (dollars) {
63
+ totals.cost += record.recordedCost;
64
+ // Pi priced it, unless its own amount is zero — which is what it records for a
65
+ // model its catalog does not know. Then nothing has priced this record.
66
+ if (record.recordedCost !== 0) continue;
67
+ }
68
+ totals.unpriced += 1;
69
+ const key = rateKey(record);
70
+ unpriced.set(key, (unpriced.get(key) ?? 0) + 1);
62
71
  }
72
+ totals.unpricedModels = [...unpriced.entries()]
73
+ .map(([key, count]): UnpricedModel => ({ key, records: count }))
74
+ .sort((a, b) => b.records - a.records || a.key.localeCompare(b.key));
63
75
  return totals;
64
76
  };
65
77
  }
66
78
 
79
+ /**
80
+ * The `price.rates` key that would price this record.
81
+ *
82
+ * `*` stands in for a side the record does not name, because that is what the lookup
83
+ * matches; a model id that contains a slash is kept whole, the way OpenRouter's ids are.
84
+ */
85
+ function rateKey(record: UsageRecord): string {
86
+ return `${record.provider ?? "*"}/${record.model ?? "*"}`;
87
+ }
88
+
67
89
  /** One record's cost at its matched rates, after the peak schedule. */
68
90
  function ratedCost(
69
91
  config: ByteflyConfig,
@@ -72,12 +72,12 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
72
72
  if (!budget || ctx.mode !== "tui") return;
73
73
  const usage = budget.read.peek();
74
74
  if (!usage) return;
75
- // CNY records no rate for a model, so its spend is simply absent from the
76
- // total. USD never counts one: Pi's recorded amount covers every model.
77
- if (!warnedUnpriced && usage.unpriced > 0) {
75
+ // A model with no rate contributes nothing: CNY has no fallback, and USD's fallback
76
+ // is Pi's own amount, which is zero for a model its catalog cannot price either.
77
+ if (!warnedUnpriced && usage.unpricedModels.length > 0) {
78
78
  warnedUnpriced = true;
79
- pi.sendMessage(unpricedNotice(usage.unpriced));
80
- deps.notify?.(unpricedBody(usage.unpriced));
79
+ pi.sendMessage(unpricedNotice(usage.unpricedModels));
80
+ deps.notify?.(unpricedBody(usage.unpricedModels));
81
81
  }
82
82
  if (warned || budget.limit <= 0) return;
83
83
  const state = budgetState(budget.limit, usage.cost);
@@ -9,14 +9,25 @@
9
9
 
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component } from "@earendil-works/pi-tui";
12
+ import { RenderCache } from "../../../../lib/tui/render-cache.ts";
13
+ import { snapshotKey } from "./key.ts";
12
14
  import { renderLine } from "./layout.ts";
13
15
  import { buildLines } from "./lines.ts";
14
16
  import type { FooterData, StatusSnapshot, TuiLike } from "../types.ts";
15
17
 
16
- /** A two-line, width-adaptive status bar. */
18
+ /**
19
+ * A two-line, width-adaptive status bar.
20
+ *
21
+ * The bar is derived from a snapshot the reader rebuilds every frame, so it memoizes on a
22
+ * key rather than on the snapshot's identity: the width, the theme object (its strings are
23
+ * what the memo holds), and `snapshotKey`'s walk over every field the bar could draw. A
24
+ * frame where nothing moved costs the key and a comparison; a frame that moved pays the key
25
+ * as well, which is why the key is measured against the build it saves.
26
+ */
17
27
  class StatusBar implements Component {
18
28
  /** Unsubscribes the branch watcher; set by `createFooter`. */
19
29
  dispose?: () => void;
30
+ private readonly cache = new RenderCache();
20
31
 
21
32
  constructor(
22
33
  private readonly read: () => StatusSnapshot,
@@ -25,15 +36,18 @@ class StatusBar implements Component {
25
36
  ) {}
26
37
 
27
38
  invalidate(): void {
28
- // The bar recomputes from a fresh snapshot each render, so there is
29
- // nothing cached to clear. Theme-dependent strings are built in render.
39
+ // Pi calls this on a theme change; the key holds the theme's identity too, so this
40
+ // is what drops themed strings a rebuild would otherwise keep.
41
+ this.cache.invalidate();
30
42
  }
31
43
 
32
44
  render(width: number): string[] {
33
45
  const snapshot = this.read();
34
46
  const theme = this.getTheme();
35
47
  const target = Math.max(1, width);
36
- return buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme));
48
+ return this.cache.keyed([target, theme, snapshotKey(snapshot)], () =>
49
+ buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme)),
50
+ );
37
51
  }
38
52
  }
39
53
 
@@ -39,6 +39,24 @@ export function formatCost(cost: number, currency: Currency = DEFAULT_CURRENCY):
39
39
  return `${symbol}${cost.toFixed(decimals)}`;
40
40
  }
41
41
 
42
+ /**
43
+ * A budget ceiling, in the configured currency, with trailing zeros dropped.
44
+ *
45
+ * A ceiling is not a price: it is the round number the user typed, and the bar
46
+ * pairs it with the spend (`$0.31/$5`), where the currency's display precision
47
+ * would spend two of the bar's columns on `$5.000`. The budget notice names the
48
+ * same figure through here, so the bar and the notice cannot disagree.
49
+ */
50
+ export function formatLimit(limit: number, currency: Currency = DEFAULT_CURRENCY): string {
51
+ const { decimals } = currency;
52
+ const symbol = CURRENCY_SYMBOLS[currency.code];
53
+ if (!Number.isFinite(limit) || limit <= 0) return `${symbol}${(0).toFixed(decimals)}`;
54
+ const minimum = 10 ** -decimals;
55
+ if (limit < minimum) return `${symbol}<${minimum.toFixed(decimals)}`;
56
+ const text = limit.toFixed(decimals);
57
+ return `${symbol}${text.includes(".") ? text.replace(/\.?0+$/, "") : text}`;
58
+ }
59
+
42
60
  /** Rounded percentage, or `?` when the value is unknown. */
43
61
  export function formatPercent(percent: number | null): string {
44
62
  return percent === null ? "?" : `${Math.round(percent)}%`;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A key for one status snapshot: everything the bar draws, and nothing about identity.
3
+ *
4
+ * The footer repaints on every frame, and its snapshot is a fresh object each time — the
5
+ * reader rebuilds it from the session and the host, and prices only what moved — so a memo
6
+ * cannot key on the snapshot itself. It keys on this string instead, and this string is
7
+ * derived from the snapshot's own shape: object keys sorted, a `Map` walked as sorted
8
+ * entries, a list by its items, a primitive by its value.
9
+ *
10
+ * That derivation is the point. A hand-written list of the fields a bar reads is exactly
11
+ * the kind of list that goes stale silently — a field added to `StatusSnapshot` and
12
+ * forgotten here is a bar that keeps showing the old value until something else moves —
13
+ * whereas a walk over the object cannot miss a field that exists.
14
+ *
15
+ * Lengths prefix strings and collection sizes, so no two different snapshots can produce
16
+ * one key by running values together.
17
+ */
18
+
19
+ import type { StatusSnapshot } from "../types.ts";
20
+
21
+ /** The key for one snapshot: equal strings mean a frame would draw the same bar. */
22
+ export function snapshotKey(snapshot: StatusSnapshot): string {
23
+ return keyPart(snapshot);
24
+ }
25
+
26
+ /** One value's part of the key, tagged by kind so `1` and `"1"` cannot collide. */
27
+ function keyPart(value: unknown): string {
28
+ if (value === undefined) return "~";
29
+ if (value === null) return "n";
30
+ if (typeof value === "number") return String(value);
31
+ if (typeof value === "boolean") return value ? "t" : "f";
32
+ if (typeof value === "string") return `${value.length}:${value}`;
33
+ if (value instanceof Map) {
34
+ // Sorted, because a Map's insertion order is not a fact about what the bar draws.
35
+ const entries = [...value.entries()].map(([key, entry]) => `${keyPart(String(key))}=${keyPart(entry)}`);
36
+ entries.sort();
37
+ return `m${value.size}{${entries.join(",")}}`;
38
+ }
39
+ if (Array.isArray(value)) return `a${value.length}[${value.map(keyPart).join(",")}]`;
40
+ if (typeof value === "object") {
41
+ const record = value as Record<string, unknown>;
42
+ const keys = Object.keys(record).sort();
43
+ return `o${keys.length}{${keys.map((key) => `${key}:${keyPart(record[key])}`).join(",")}}`;
44
+ }
45
+ return String(value);
46
+ }
@@ -125,16 +125,26 @@ export function renderLine(spec: LineSpec, width: number, theme: Theme): string
125
125
  const target = Math.max(1, width);
126
126
  const states = makeStates(spec);
127
127
 
128
+ // Each pass keeps the line it measured, so a decision never costs a second assembly and
129
+ // a line that already fits never reaches `truncateToWidth` — which walks the string
130
+ // character by character (14 µs on a footer line, against 0.2 µs for the width scan) and
131
+ // is the whole reason this function was measurable per frame.
132
+ let line = assemble(states, target);
133
+ let measured = visibleWidth(line);
128
134
  let guard = 0;
129
- while (visibleWidth(assemble(states, target)) > target && guard++ < states.length * 8 + 16) {
135
+ while (measured > target && guard++ < states.length * 8 + 16) {
130
136
  if (!reduceOnce(states)) break;
137
+ line = assemble(states, target);
138
+ measured = visibleWidth(line);
131
139
  }
132
140
  // Only once every segment is at its floor do we shrink separators, so the
133
141
  // truncation ellipsis never replaces a separator's content and adjacent
134
142
  // segments always keep at least one column of padding between them.
135
- while (visibleWidth(assemble(states, target)) > target) {
143
+ while (measured > target) {
136
144
  if (!collapseSeparator(states)) break;
145
+ line = assemble(states, target);
146
+ measured = visibleWidth(line);
137
147
  }
138
148
 
139
- return truncateToWidth(assemble(states, target), target, paint(theme, "faint", "…"));
149
+ return measured > target ? truncateToWidth(line, target, paint(theme, "faint", "…")) : line;
140
150
  }
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * Turn a `StatusSnapshot` into the two footer lines.
3
3
  *
4
- * Line 1 — identity: left `pwd`, right `⎇ branch · ⑂ worktree · ◉ browse`.
5
- * Line 2 — resources: left the plan/alert statuses then the context gauge and
6
- * usage meters, right the model and thinking level.
4
+ * Line 1 — state: left `pwd` and the session name, right the alert, the mode
5
+ * chips, then `⎇ branch · ⑂ worktree · ◉ 4780`. Line 2 — meters: left
6
+ * the context gauge and usage, right the model and thinking level.
7
+ *
8
+ * The split is one job per row. The state chips change while a session runs, so
9
+ * they ride the line with slack; the meters are the dense row, and a fact that
10
+ * moves onto the top row is one the bottom row stops dropping to fit.
7
11
  */
8
12
 
9
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
14
  import {
15
+ alertSegment,
11
16
  branchSegment,
12
17
  cacheSegment,
13
18
  contextSegment,
@@ -15,8 +20,8 @@ import {
15
20
  delegationSegment,
16
21
  modelSegment,
17
22
  modesSegment,
23
+ portsSegment,
18
24
  pwdSegment,
19
- browseSegment,
20
25
  sessionSegment,
21
26
  thinkingSegment,
22
27
  tokensSegment,
@@ -29,11 +34,23 @@ function defined(segments: Array<Segment | null>): Segment[] {
29
34
  return segments.filter((segment): segment is Segment => segment !== null);
30
35
  }
31
36
 
32
- /** Build the identity and resources lines for a snapshot. */
37
+ /** Build the state and meter lines for a snapshot. */
33
38
  export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string | undefined): LineSpec[] {
34
39
  const line1: LineSpec = {
35
40
  left: defined([pwdSegment(snapshot, theme, home), sessionSegment(snapshot, theme)]),
36
- right: defined([branchSegment(snapshot, theme), worktreeSegment(snapshot, theme), browseSegment(snapshot, theme)]),
41
+ // The state chips lead the right zone: it is right-aligned, so a chip that
42
+ // appears at its start leaves the location chips where they were, the same
43
+ // stability the gauge gets by sitting at column 0.
44
+ right: defined([
45
+ alertSegment(snapshot, theme),
46
+ modesSegment(snapshot, theme),
47
+ branchSegment(snapshot, theme),
48
+ worktreeSegment(snapshot, theme),
49
+ // Last, so the page port rides behind the location chips. A page starting or
50
+ // stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays the
51
+ // adjacent pair a reader scans for where the work lands.
52
+ portsSegment(snapshot, theme),
53
+ ]),
37
54
  };
38
55
 
39
56
  const line2Left = defined([
@@ -43,7 +60,6 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
43
60
  delegationSegment(snapshot, theme),
44
61
  tokensSegment(snapshot, theme),
45
62
  cacheSegment(snapshot, theme),
46
- modesSegment(snapshot, theme),
47
63
  ]);
48
64
 
49
65
  const line2Right = defined([modelSegment(snapshot, theme), thinkingSegment(snapshot, theme)]);
@@ -13,8 +13,17 @@ import { formatTokens, sanitize, sanitizeLine } from "../../../../lib/format.ts"
13
13
  import { gaugeBar } from "../../../../lib/tui/gauge.ts";
14
14
  import { chip } from "../../../../lib/tui/rows.ts";
15
15
  import { paint } from "../../../../lib/tui/tokens.ts";
16
+ import { budgetState } from "../budget.ts";
16
17
  import { CONFIG } from "../config.ts";
17
- import { contextRole, formatCost, formatCwd, formatPercent, thinkingColor, truncateLabel } from "./format.ts";
18
+ import {
19
+ contextRole,
20
+ formatCost,
21
+ formatCwd,
22
+ formatLimit,
23
+ formatPercent,
24
+ thinkingColor,
25
+ truncateLabel,
26
+ } from "./format.ts";
18
27
  import { compactStatus, dropStatusDetail } from "./status.ts";
19
28
  import type { Segment, StatusSnapshot } from "../types.ts";
20
29
 
@@ -91,38 +100,61 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
91
100
  return { id: "worktree", weight: 3, droppable: true, separator, forms };
92
101
  }
93
102
 
94
- export function browseSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
95
- const raw = snapshot.statuses.get(CONFIG.browseStatusKey);
96
- if (!raw) return null;
97
- const icon = CONFIG.icons.browse;
98
- const label = chipLabel(raw);
99
- const separator = faint(theme, CONFIG.separators.item);
100
- if (!label) {
101
- return { id: "browse", weight: 3, droppable: true, separator, forms: [paint(theme, "ok", icon)] };
102
- }
103
- // A two-tone chip: the `◉` glyph stays green while the port reads as accent.
103
+ /**
104
+ * The session's page port, while anything is served on it.
105
+ *
106
+ * One server serves every page of a session, so there is one number to draw: the chip
107
+ * the `serve` extension publishes, with the glyph alone as the form that survives a bar
108
+ * too narrow for a port.
109
+ */
110
+ export function portsSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
111
+ const chip = snapshot.statuses.get(CONFIG.httpStatusKey);
112
+ if (chip === undefined) return null;
113
+ const icon = paint(theme, "ok", CONFIG.icons.http);
114
+ const port = chipLabel(chip);
104
115
  return {
105
- id: "browse",
116
+ id: "ports",
106
117
  weight: 3,
107
118
  droppable: true,
108
- separator,
109
- forms: [
110
- `${paint(theme, "ok", icon)} ${paint(theme, "accent", label)}`,
111
- `${paint(theme, "ok", icon)}${paint(theme, "accent", label)}`,
112
- paint(theme, "ok", icon),
113
- ],
119
+ separator: faint(theme, CONFIG.separators.item),
120
+ forms: [...new Set([port ? `${icon} ${paint(theme, "accent", port)}` : icon, icon])],
114
121
  };
115
122
  }
116
123
 
117
- /** Sort rank: an alert (a failed job) leads the slot; modes follow by key. */
118
- function statusRank(key: string): number {
119
- return key === CONFIG.jobsFailureStatusKey ? 0 : 1;
124
+ /** Whether a status key names an alert instead of a mode. */
125
+ function isAlert(key: string): boolean {
126
+ return CONFIG.alertStatusKeys.includes(key);
127
+ }
128
+
129
+ /**
130
+ * The alerts: a failed job, and anything else whose chip means "this broke".
131
+ *
132
+ * Not droppable, because every other fact on the bar can be read again from
133
+ * somewhere — the branch from git, the mode from the session — and a failure
134
+ * that scrolled off the bar is a failure nobody is looking at. It gives up its
135
+ * text last, keeping the count (`✗1`) instead of collapsing to a bare glyph.
136
+ */
137
+ export function alertSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
138
+ const alerts = [...snapshot.statuses.entries()]
139
+ .filter(([key]) => isAlert(key))
140
+ .sort(([a], [b]) => a.localeCompare(b))
141
+ .map(([, value]) => sanitize(value))
142
+ .filter(Boolean);
143
+ if (alerts.length === 0) return null;
144
+ const separator = faint(theme, CONFIG.separators.item);
145
+ return {
146
+ id: "alert",
147
+ weight: 1,
148
+ droppable: false,
149
+ separator,
150
+ forms: [alerts.join(separator), alerts.map((status) => compactStatus(status)).join(" ")],
151
+ };
120
152
  }
121
153
 
122
154
  export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
123
155
  const others = [...snapshot.statuses.entries()]
124
- .filter(([key]) => key !== CONFIG.worktreeStatusKey && key !== CONFIG.browseStatusKey)
125
- .sort(([a], [b]) => statusRank(a) - statusRank(b) || a.localeCompare(b))
156
+ .filter(([key]) => key !== CONFIG.worktreeStatusKey && key !== CONFIG.httpStatusKey && !isAlert(key))
157
+ .sort(([a], [b]) => a.localeCompare(b))
126
158
  .map(([key, value]) => {
127
159
  const status = sanitize(value);
128
160
  // The plan chip trails the plan file name; the slot shows the mode only.
@@ -137,11 +169,11 @@ export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
137
169
  const icons = others.map((status) => compactStatus(status)).join(" ");
138
170
  return {
139
171
  id: "statuses",
140
- // The last thing to go, but droppable: a tight bar drops the slot rather
141
- // than truncating the model, which sits at the end of the line.
142
- weight: 1,
172
+ // Weight 2, ahead of the branch: a mode is stated again by the session, while
173
+ // `⎇` is the only thing on the bar that says where the work will land.
174
+ weight: 2,
143
175
  droppable: true,
144
- separator: faint(theme, CONFIG.separators.group),
176
+ separator: faint(theme, CONFIG.separators.item),
145
177
  forms: [full, icons],
146
178
  };
147
179
  }
@@ -170,15 +202,42 @@ export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
170
202
  };
171
203
  }
172
204
 
205
+ /**
206
+ * What the session cost, and how many records the total leaves out.
207
+ *
208
+ * A session whose every record went unpriced has a total of zero, and a segment that
209
+ * vanished at zero was the loudest version of the silence: nothing on the bar said the
210
+ * work was unpriced rather than free. So the count is its own fact, and the price comes
211
+ * first where there is one — the narrower forms drop the price before the warning.
212
+ *
213
+ * A configured budget turns the price into a position: `$0.31/$5`, drawn in `warn` once
214
+ * the spend reaches the ceiling. That is the condition the budget notice fires on, read
215
+ * from the same `budgetState`, so the bar cannot call a session over budget that the
216
+ * notice has not, and the ceiling is visible from the first frame rather than only in
217
+ * the one notice that names it.
218
+ */
173
219
  export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
174
- const { cost } = snapshot.usage;
175
- if (cost === 0) return null;
220
+ const { cost, unpriced } = snapshot.usage;
221
+ const budget = snapshot.budget;
222
+ if (cost === 0 && unpriced === 0 && budget <= 0) return null;
223
+ const price = formatCost(cost, snapshot.currency);
224
+ const spend = budget > 0 ? `${price}/${formatLimit(budget, snapshot.currency)}` : price;
225
+ const spendText = paint(theme, budgetState(budget, cost).exceeded ? "warn" : "meta", spend);
226
+ const leftOut = unpriced === 1 ? "1 unpriced" : `${unpriced} unpriced`;
227
+ // A zero total with no ceiling says nothing a reader can use, so the price form
228
+ // appears only once there is a spend or a budget to hold it against.
229
+ const priced = cost === 0 && budget <= 0 ? [] : [spendText];
230
+ const forms = [
231
+ `${spendText}${faint(theme, CONFIG.separators.item)}${paint(theme, "warn", leftOut)}`,
232
+ paint(theme, "warn", leftOut),
233
+ ...priced,
234
+ ];
176
235
  return {
177
236
  id: "cost",
178
237
  weight: 2,
179
238
  droppable: true,
180
239
  separator: faint(theme, CONFIG.separators.item),
181
- forms: [paint(theme, "meta", formatCost(cost, snapshot.currency))],
240
+ forms: unpriced === 0 ? [spendText] : forms,
182
241
  };
183
242
  }
184
243
 
@@ -256,7 +315,10 @@ export function modelSegment(snapshot: StatusSnapshot, theme: Theme): Segment {
256
315
  forms: [paint(theme, "meta", CONFIG.labels.noModel)],
257
316
  };
258
317
  }
259
- const provider = snapshot.providerCount > 1 ? `(${model.provider}) ` : "";
318
+ // Only several providers make the name ambiguous, and an id that already carries
319
+ // its provider (`deepseek/deepseek-v4-pro`) would only say it twice.
320
+ const qualify = snapshot.providerCount > 1 && !model.id.toLowerCase().startsWith(model.provider.toLowerCase());
321
+ const provider = qualify ? `(${model.provider}) ` : "";
260
322
  return {
261
323
  id: "model",
262
324
  weight: 1,
@@ -37,14 +37,30 @@ export interface UsageTotals {
37
37
  }
38
38
 
39
39
  /**
40
- * The totals plus how many records the mode had no rate for.
40
+ * One model this session could not price, as the key that would price it.
41
41
  *
42
- * CNY has no fallback, so those records contribute no cost at all; the count is
43
- * what lets the session say so instead of quietly showing a total that omits
44
- * them. USD falls back to Pi's recorded amount, so it never counts any.
42
+ * The key is the `price.rates` vocabulary — `provider/model`, with `*` standing in
43
+ * for a side the record does not name — so it is copy-pasteable into the config.
44
+ */
45
+ export interface UnpricedModel {
46
+ key: string;
47
+ /** How many records in this session carried it. */
48
+ records: number;
49
+ }
50
+
51
+ /**
52
+ * The totals plus the records the mode had no rate for.
53
+ *
54
+ * CNY has no fallback, so those records contribute no cost at all. USD falls back
55
+ * to Pi's recorded amount — except when that amount is `0`, which is what Pi
56
+ * records for a model its own catalog cannot price, and then nothing has priced
57
+ * the record either. Both cases want the same fix, so they are one list: the
58
+ * models, and the key that would price each. `unpriced` is summed from it, so the
59
+ * count and the names cannot disagree.
45
60
  */
46
61
  export interface PricedUsage extends UsageTotals {
47
62
  unpriced: number;
63
+ unpricedModels: UnpricedModel[];
48
64
  }
49
65
 
50
66
  /** Context-window usage for the active model. */
@@ -75,9 +91,17 @@ export interface StatusSnapshot {
75
91
  model: ModelInfo | null;
76
92
  thinkingLevel: ThinkingLevel | null;
77
93
  context: ContextInfo;
78
- usage: UsageTotals;
94
+ /** The session's totals, with the records nothing could price riding along. */
95
+ usage: PricedUsage;
79
96
  /** How `usage.cost` is rendered. */
80
97
  currency: Currency;
98
+ /**
99
+ * The session's configured spend ceiling in `currency`; `0` means no ceiling.
100
+ *
101
+ * The bar pairs it with the cost (`$0.31/$5`) so the allowance is a fact on the
102
+ * screen, not something the one budget notice alone carries.
103
+ */
104
+ budget: number;
81
105
  /** Latest cache hit rate as a percentage, or `null` when unknown. */
82
106
  cacheHitRate: number | null;
83
107
  providerCount: number;
@@ -1,36 +1,61 @@
1
1
  /**
2
- * The one notice a CNY session posts when records had no rate.
2
+ * The one notice a session posts when records had no rate.
3
3
  *
4
- * CNY mode has no fallback, so a model the table cannot price contributes no
5
- * cost at all; the footer's total is then smaller than the work done, with
6
- * nothing on screen saying why. This is the one sentence that says it, once per
7
- * session, and names the two honest fixes rather than picking one: switch to
8
- * `USD` (Pi's catalog is already per model, tiers included) or state yuan rates
9
- * under `price.rates`.
4
+ * A record nothing priced contributes no cost at all — CNY has no fallback, and USD
5
+ * falls back to Pi's recorded amount, which is `0` for a model its catalog does not
6
+ * know — so the footer's total is smaller than the work done, with nothing on screen
7
+ * saying why. This is the one sentence that says it, once per session, and it names the
8
+ * models by the key that would price them rather than only counting records: a count
9
+ * tells a reader the total is short, and a key tells them what to do about it.
10
10
  *
11
- * Pure: the caller has the count, this shapes the answer. No figure is quoted,
12
- * because there is none to quote.
11
+ * Two fixes are honest and the notice does not pick for the reader: switch to `USD`
12
+ * (Pi's catalog is already per model, tiers included) or state yuan rates under
13
+ * `price.rates`. No figure is quoted, because there is none to quote.
13
14
  */
14
15
 
15
16
  import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
17
+ import type { UnpricedModel } from "./types.ts";
18
+
19
+ /** How many keys the notice and its OS body name before they count the rest. */
20
+ export const NAMED_MODELS = 3;
21
+
22
+ /** The keys to print, most records first, and how many were left out. */
23
+ function named(models: readonly UnpricedModel[]): string {
24
+ const shown = models.slice(0, NAMED_MODELS).map((model) => model.key);
25
+ const rest = models.length - shown.length;
26
+ return rest > 0 ? `${shown.join(", ")} +${rest} more` : shown.join(", ");
27
+ }
28
+
29
+ /**
30
+ * What a count of records reads as: `1 record`, `2 records`.
31
+ *
32
+ * Exported because the notice, its OS body, and `/bytefly prices` all say it, and three
33
+ * wordings for one fact is how a report starts disagreeing with the message beside it.
34
+ */
35
+ export function recordsText(count: number): string {
36
+ return count === 1 ? "1 record" : `${count} records`;
37
+ }
16
38
 
17
39
  /** The notice naming what the total leaves out, and how to price it. */
18
- export function unpricedNotice(count: number): NoticeMessage {
40
+ export function unpricedNotice(models: readonly UnpricedModel[]): NoticeMessage {
41
+ const count = models.reduce((total, model) => total + model.records, 0);
19
42
  const subject = count === 1 ? "1 record has" : `${count} records have`;
20
43
  return noticeMessage({
21
44
  content:
22
- `${subject} no CNY rate, so ${count === 1 ? "it is" : "they are"} not in this total. ` +
23
- "Switch `price.currency.code` to USD to use Pi's recorded cost, or add yuan `price.rates` entries.",
45
+ `${subject} no rate, so ${count === 1 ? "it is" : "they are"} not in this total: ${named(models)}. ` +
46
+ "Switch `price.currency.code` to USD to use Pi's recorded cost, or add `price.rates` entries for those keys.",
24
47
  details: {
25
48
  source: "bytefly",
26
- title: "Records with no CNY rate",
49
+ title: "Records with no rate",
27
50
  severity: "info",
28
- items: [{ label: "records", detail: String(count) }],
51
+ items: models.slice(0, NAMED_MODELS).map((model) => ({ label: model.key, detail: recordsText(model.records) })),
29
52
  },
30
53
  });
31
54
  }
32
55
 
33
- /** The one-line body an OS notification carries. */
34
- export function unpricedBody(count: number): string {
35
- return `${count} record${count === 1 ? "" : "s"} with no CNY rate · use USD or add price.rates`;
56
+ /** The one-line body an OS notification carries: the first key is the actionable part. */
57
+ export function unpricedBody(models: readonly UnpricedModel[]): string {
58
+ const count = models.reduce((total, model) => total + model.records, 0);
59
+ const first = models[0]?.key ?? "";
60
+ return `${recordsText(count)} with no rate · ${first} · use USD or add price.rates`;
36
61
  }