@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -127,23 +127,24 @@ const SECTION_LABELS: Record<PanelSection, string> = {
127
127
  settings: "Settings",
128
128
  };
129
129
 
130
+ /** One section's name, as the tab row prints it. */
131
+ export function sectionLabel(section: PanelSection): string {
132
+ return SECTION_LABELS[section];
133
+ }
134
+
130
135
  /**
131
- * The section bar's segments, in screen order. The active section takes the
132
- * accent and the others recede, so the bar reads as a left-to-right switch.
136
+ * The header's attention line: the worst thing the screen knows, then how many
137
+ * changes a reload would apply. `all clear` is said out loud rather than left
138
+ * blank, so the header never reads as "nothing loaded".
133
139
  */
134
- export function sectionSegments(active: PanelSection): SummarySegment[] {
135
- return SECTION_ORDER.map((section) => ({
136
- text: SECTION_LABELS[section],
137
- color: active === section ? "accent" : "faint",
138
- }));
139
- }
140
-
141
- /** The Settings section's counts: the scope and how many rows it shows. */
142
- export function settingsSummarySegments(scope: PanelScope, count: number): SummarySegment[] {
143
- return [
144
- { text: scope === "global" ? "Global" : "Project", color: "accent" },
145
- { text: `${count} settings`, color: "meta" },
146
- ];
140
+ export function attentionSegments(counts: PanelCounts, dirty: boolean): SummarySegment[] {
141
+ const segments: SummarySegment[] = [];
142
+ if (counts.missing > 0) segments.push({ text: `${counts.missing} missing`, color: "bad" });
143
+ else if (counts.degraded > 0) segments.push({ text: `${counts.degraded} degraded`, color: "warn" });
144
+ else if (counts.off > 0) segments.push({ text: `${counts.off} off`, color: "faint" });
145
+ else segments.push({ text: "all clear", color: "ok" });
146
+ if (dirty) segments.push({ text: "unsaved changes", color: "warn" });
147
+ return segments;
147
148
  }
148
149
 
149
150
  /**
@@ -31,6 +31,17 @@ export function extensionName(path: string): string {
31
31
  return basename(dirname(path));
32
32
  }
33
33
 
34
+ /**
35
+ * The name a resource is called by on screen: the directory for an entry file
36
+ * (`…/worktree/index.ts`, `…/pdf/SKILL.md`), and the file's own stem otherwise, so
37
+ * a flat `extensions/auto.ts` is `auto` rather than the directory every flat file
38
+ * shares.
39
+ */
40
+ export function resourceName(path: string): string {
41
+ const stem = basename(path).replace(/\.(ts|js|mjs|cjs|md)$/u, "");
42
+ return stem === "index" || stem === "SKILL" ? basename(dirname(path)) : stem;
43
+ }
44
+
34
45
  /** Row key for a report that has no resolved extension item. */
35
46
  function reportKey(name: string): string {
36
47
  return `report:${name}`;
@@ -46,13 +57,24 @@ export interface PanelJoinOptions {
46
57
  agentDir: string;
47
58
  /** Narrow to one extension directory or report name, or `undefined` for the full list. */
48
59
  filter?: string;
60
+ /** Keep only the rows this accepts; a group's count follows what survives. */
61
+ where?: (row: PanelRow) => boolean;
49
62
  /** Which resource kind the list is, for labels and grouping. */
50
63
  kind?: ResourceKind;
51
64
  }
52
65
 
66
+ /**
67
+ * The rows one group list produces, group headings included. The screen measures
68
+ * a section's height from this rather than from a built model, so a section it is
69
+ * not showing costs no `SKILL.md` read.
70
+ */
71
+ export function groupedEntryCount(items: readonly ResourceItem[], agentDir: string, kind: ResourceKind): number {
72
+ return buildEntries([...items], agentDir, kind).length;
73
+ }
74
+
53
75
  /** Build the merged model for the current items and reports. */
54
76
  export function buildPanelModel(options: PanelJoinOptions): PanelModel {
55
- const { items, reports, agentDir, filter, kind = "extensions" } = options;
77
+ const { items, reports, agentDir, filter, where, kind = "extensions" } = options;
56
78
  const byName = new Map(reports.map((report) => [report.extension, report]));
57
79
 
58
80
  // A directory name may claim a report only when exactly one unique resource
@@ -60,7 +82,7 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
60
82
  // the report and it stays available to the trailing `Not resolved` group.
61
83
  const pathsByName = new Map<string, Set<string>>();
62
84
  for (const item of items) {
63
- const name = extensionName(item.path);
85
+ const name = resourceName(item.path);
64
86
  const paths = pathsByName.get(name) ?? new Set<string>();
65
87
  paths.add(resourceKey(item));
66
88
  pathsByName.set(name, paths);
@@ -83,12 +105,23 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
83
105
  const report = unambiguous(name) ? byName.get(name) : undefined;
84
106
  if (report) claimed.add(name);
85
107
  if (filter !== undefined && name !== filter) continue;
86
- rows.push({ key: resourceKey(item), item, report, label: entry.label, group: current?.key ?? "" });
108
+ // The row is named for the resource, not for its file: a name is only unique
109
+ // enough to be a name when one resource uses it, which is the same rule that
110
+ // lets a directory claim a report. Otherwise the picker's `folder/file.ts`
111
+ // stays, because two rows would otherwise read alike.
112
+ const label = unambiguous(name) ? resourceName(item.path) : entry.label;
113
+ const row: PanelRow = { key: resourceKey(item), item, report, label, group: current?.key ?? "" };
114
+ if (where !== undefined && !where(row)) continue;
115
+ rows.push(row);
87
116
  if (current) current.count += 1;
88
117
  }
89
118
 
90
119
  const extras = reports.filter(
91
- (report) => !claimed.has(report.extension) && (filter === undefined || report.extension === filter),
120
+ (report) =>
121
+ !claimed.has(report.extension) &&
122
+ (filter === undefined || report.extension === filter) &&
123
+ (where === undefined ||
124
+ where({ key: reportKey(report.extension), report, label: report.extension, group: REPORT_ONLY_GROUP })),
92
125
  );
93
126
  if (extras.length > 0) {
94
127
  const group: PanelGroup = { key: REPORT_ONLY_GROUP, label: REPORT_ONLY_LABEL, count: extras.length };
@@ -111,14 +144,24 @@ export function buildSkillModel(options: {
111
144
  items: readonly ResourceItem[];
112
145
  index: ReadonlyMap<string, SkillInfo>;
113
146
  agentDir: string;
147
+ where?: (row: PanelRow) => boolean;
114
148
  }): PanelModel {
115
- const model = buildPanelModel({ items: options.items, reports: [], agentDir: options.agentDir, kind: "skills" });
116
- return {
117
- groups: model.groups,
118
- rows: model.rows.map((row) => {
119
- const skill = row.item ? options.index.get(row.item.path) : undefined;
120
- if (skill === undefined) return row;
121
- return { ...row, label: skill.name ?? row.label, skill };
122
- }),
123
- };
149
+ // The filter runs after the join: a skill's name and its `SKILL.md` facts are
150
+ // what a reader searches and what decides whether it needs attention.
151
+ const model = buildPanelModel({
152
+ items: options.items,
153
+ reports: [],
154
+ agentDir: options.agentDir,
155
+ kind: "skills",
156
+ });
157
+ const joined = model.rows.map((row) => {
158
+ const skill = row.item ? options.index.get(row.item.path) : undefined;
159
+ if (skill === undefined) return row;
160
+ return { ...row, label: skill.name ?? row.label, skill };
161
+ });
162
+ const rows = options.where === undefined ? joined : joined.filter(options.where);
163
+ const groups = model.groups
164
+ .map((group) => ({ ...group, count: rows.filter((row) => row.group === group.key).length }))
165
+ .filter((group) => group.count > 0);
166
+ return { groups, rows };
124
167
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `/bytefly prices` report: which models this session could not price.
3
+ *
4
+ * A record nothing priced contributes no cost, so the footer's total is smaller than
5
+ * the work done and the reason is a *model*, not a record count. This is the answer to
6
+ * the question the footer can only hint at: the keys, in the `price.rates` vocabulary,
7
+ * and the one line saying what to do with them.
8
+ *
9
+ * Nothing here quotes a price: a rate is the user's to state, and inventing one is the
10
+ * rate table this command exists instead of.
11
+ */
12
+
13
+ import { sanitizeLine } from "../../../lib/format.ts";
14
+ import type { PricedUsage } from "../footer/types.ts";
15
+ import { formatCost } from "../footer/render/format.ts";
16
+ import { recordsText } from "../footer/unpriced.ts";
17
+ import type { Currency } from "../footer/price/types.ts";
18
+
19
+ /** How many keys the report prints before it counts the rest. */
20
+ export const REPORTED_MODELS = 8;
21
+
22
+ /**
23
+ * The report as the lines a reader gets.
24
+ *
25
+ * The headline says what the total is and how much of the session it leaves out; one
26
+ * line per key says what to add; the last line says where to add it. Pure: the caller
27
+ * has already priced the session.
28
+ */
29
+ export function pricesReport(priced: PricedUsage, currency: Currency): string[] {
30
+ const models = priced.unpricedModels;
31
+ if (models.length === 0) return ["bytefly: every record in this session has a rate — nothing to add."];
32
+ const count = models.reduce((total, model) => total + model.records, 0);
33
+ const headline =
34
+ `bytefly: ${formatCost(priced.cost, currency)} priced · ` +
35
+ `${recordsText(count)} in ${models.length === 1 ? "1 model" : `${models.length} models`} ${count === 1 ? "has" : "have"} no rate`;
36
+ const keys = models
37
+ .slice(0, REPORTED_MODELS)
38
+ .map((model) => ` ${sanitizeLine(model.key)} · ${recordsText(model.records)}`);
39
+ const rest = models.length - keys.length;
40
+ if (rest > 0) keys.push(` +${rest} more`);
41
+ keys.push(' add these under "price.rates" in bytefly.json — a key may be `*`, or a prefix like `gpt-*`.');
42
+ return [headline, ...keys];
43
+ }
@@ -9,7 +9,7 @@
9
9
  * The catalogue is the shortlist a session changes, not the whole document: every
10
10
  * row is one a workflow differs on, and each states its own rule so a value the
11
11
  * screen accepts is a value the loader keeps. The catalogue order is the screen's
12
- * order, and it is capped so the section fits a 24-row terminal (`panel.test.ts`
12
+ * order, and it is capped so the section fits a 24-row terminal (`settings.test.ts`
13
13
  * holds the ceiling); a new row has to displace one. Everything else stays
14
14
  * `bytefly.json` — a list (`doctor.skip`), a table (`price.rates`), a schedule
15
15
  * (`price.peak`), and the tuning numbers and labels this screen deliberately does
@@ -28,9 +28,11 @@ import { configSection, configValueAt } from "../../../lib/config.ts";
28
28
  import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
29
29
  import {
30
30
  CURRENCY_DEFAULTS,
31
+ CURRENCY_SYMBOLS,
31
32
  DEFAULT_BYTEFLY_CONFIG,
32
33
  DEFAULT_CURRENCY,
33
34
  MAX_BUDGET,
35
+ MAX_DECIMALS,
34
36
  normalizeByteflyConfig,
35
37
  } from "../footer/price/config.ts";
36
38
  import type { CurrencyCode } from "../footer/price/types.ts";
@@ -46,6 +48,13 @@ type SettingKind = "boolean" | "enum" | "text";
46
48
  /** Where the effective value came from. */
47
49
  type SettingOrigin = "default" | "global" | "project" | "inherited";
48
50
 
51
+ /**
52
+ * The heading a row sits under. The screen draws one heading per group, so twelve
53
+ * rows read as four short lists: what a session starts with, what it tells you
54
+ * about, what it keeps, and what it costs.
55
+ */
56
+ export type SettingGroup = "Startup" | "Notifications" | "Session" | "Price";
57
+
49
58
  /** One curated value a row can take. */
50
59
  interface SettingOption {
51
60
  value: string;
@@ -80,6 +89,8 @@ interface SettingLeaf {
80
89
  export interface SettingDescriptor {
81
90
  /** Stable identity, also the focus key. */
82
91
  id: string;
92
+ /** The heading this row is listed under. */
93
+ group: SettingGroup;
83
94
  label: string;
84
95
  description: string;
85
96
  source: SettingSource;
@@ -152,8 +163,10 @@ function amountRule(min: number, max: number): TextRule {
152
163
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
153
164
  {
154
165
  id: "byteflyUi",
155
- label: "Bytefly startup TUI",
156
- description: "Use bytefly's startup header in place of Pi's startup TUI.",
166
+ group: "Startup",
167
+ label: "Startup screen",
168
+ description:
169
+ "Show bytefly's header in place of Pi's own, so a session opens on the versions, the help keys, and the update marker.",
157
170
  source: "app",
158
171
  kind: "boolean",
159
172
  fallback: "false",
@@ -162,9 +175,10 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
162
175
  },
163
176
  {
164
177
  id: "theme",
178
+ group: "Startup",
165
179
  label: "Theme",
166
180
  description:
167
- "Pi's color theme, chosen from the palettes this package ships; `system` follows the terminal's colors. Takes effect after reload.",
181
+ "The palette every bytefly surface is drawn with; `system` follows Pi's own colors. Takes effect after reload.",
168
182
  source: "app",
169
183
  kind: "enum",
170
184
  fallback: "system",
@@ -173,8 +187,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
173
187
  },
174
188
  {
175
189
  id: "notify.enabled",
190
+ group: "Notifications",
176
191
  label: "Desktop notifications",
177
- description: "Raise an OS notification when a run or job finishes, or the agent asks for input.",
192
+ description: "The master switch for OS notifications; with it off, none of the four below is raised.",
178
193
  source: "bytefly",
179
194
  kind: "boolean",
180
195
  fallback: "false",
@@ -184,8 +199,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
184
199
  },
185
200
  {
186
201
  id: "notify.turnFinished",
187
- label: "Turn finished",
188
- description: "Announce it when a full agent run settles.",
202
+ group: "Notifications",
203
+ label: "When a turn ends",
204
+ description: "Raise a notification once the agent has finished a reply.",
189
205
  source: "bytefly",
190
206
  kind: "boolean",
191
207
  fallback: "true",
@@ -195,8 +211,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
195
211
  },
196
212
  {
197
213
  id: "notify.needsInput",
198
- label: "Needs input",
199
- description: "Announce it when the agent opens a prompt and waits for an answer.",
214
+ group: "Notifications",
215
+ label: "When the agent needs you",
216
+ description: "Raise a notification when a question or a permission prompt is waiting.",
200
217
  source: "bytefly",
201
218
  kind: "boolean",
202
219
  fallback: "true",
@@ -206,8 +223,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
206
223
  },
207
224
  {
208
225
  id: "notify.jobFinished",
209
- label: "Job finished",
210
- description: "Announce it when a background job or watcher settles.",
226
+ group: "Notifications",
227
+ label: "When a job ends",
228
+ description: "Raise a notification when a background job or a watcher settles.",
211
229
  source: "bytefly",
212
230
  kind: "boolean",
213
231
  fallback: "true",
@@ -217,8 +235,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
217
235
  },
218
236
  {
219
237
  id: "notify.matchFound",
220
- label: "Watcher matched",
221
- description: "Announce it when a `watch` whose `wake` is on matches, once the agent is idle.",
238
+ group: "Notifications",
239
+ label: "When a watcher matches",
240
+ description: "Raise a notification when a watched pattern appears, once the agent is idle.",
222
241
  source: "bytefly",
223
242
  kind: "boolean",
224
243
  fallback: "true",
@@ -228,8 +247,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
228
247
  },
229
248
  {
230
249
  id: "job.wakeOnFinish",
231
- label: "Wake when a job finishes",
232
- description: "Start one agent turn when any background job finishes.",
250
+ group: "Session",
251
+ label: "Resume after a job",
252
+ description: "Wake the agent once a background job finishes, so it can read the output.",
233
253
  source: "bytefly",
234
254
  kind: "boolean",
235
255
  fallback: "false",
@@ -239,8 +259,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
239
259
  },
240
260
  {
241
261
  id: "rewind.autoSnapshots",
242
- label: "Automatic snapshots",
243
- description: "Take one rewind snapshot per prompt, before the first mutating tool call.",
262
+ group: "Session",
263
+ label: "Snapshot before a turn",
264
+ description: "Keep one rewind point per prompt, so `/rewind` can take you back.",
244
265
  source: "bytefly",
245
266
  kind: "boolean",
246
267
  fallback: "true",
@@ -250,8 +271,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
250
271
  },
251
272
  {
252
273
  id: "doctor.proactive",
253
- label: "Proactive health check",
254
- description: "Probe enabled extensions at session start and warn about a missing requirement.",
274
+ group: "Session",
275
+ label: "Health check at startup",
276
+ description: "Probe what the extensions need before the first turn and say what is missing.",
255
277
  source: "bytefly",
256
278
  kind: "boolean",
257
279
  fallback: "true",
@@ -261,9 +283,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
261
283
  },
262
284
  {
263
285
  id: "price.currency.code",
286
+ group: "Price",
264
287
  label: "Currency",
265
- description:
266
- "CNY prices DeepSeek from its yuan rates; USD from its published dollars, falling back to Pi's recorded cost.",
288
+ description: "The currency the footer prices a session in, at that currency's published rates.",
267
289
  source: "bytefly",
268
290
  kind: "enum",
269
291
  fallback: DEFAULT_CURRENCY.code,
@@ -283,8 +305,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
283
305
  },
284
306
  {
285
307
  id: "price.budget",
308
+ group: "Price",
286
309
  label: "Session budget",
287
- description: "Warn once when the session's cost reaches this amount; 0 is off. It never stops a turn.",
310
+ description: "Where the footer's `spent/limit` warns: one notice and one notification at the limit; 0 is off.",
288
311
  source: "bytefly",
289
312
  kind: "text",
290
313
  fallback: "0",
@@ -329,6 +352,31 @@ function themeOptions(themes: readonly string[]): readonly SettingOption[] {
329
352
  return names.map((name) => ({ value: name, label: name }));
330
353
  }
331
354
 
355
+ /** The currency and precision a session prices in, through the price reader. */
356
+ function currencyOf(snapshot: SettingsSnapshot, scope: WriteScope): { code: CurrencyCode; decimals: number } {
357
+ const reader = SECTION_READERS.price;
358
+ const globalRaw = configSection(snapshot.global, "price");
359
+ const projectRaw = scope === "project" ? configSection(snapshot.project, "price") : undefined;
360
+ const code = reader?.(globalRaw, projectRaw, ["currency", "code"]);
361
+ const decimals = reader?.(globalRaw, projectRaw, ["currency", "decimals"]);
362
+ return {
363
+ code: typeof code === "string" && code in CURRENCY_SYMBOLS ? (code as CurrencyCode) : DEFAULT_CURRENCY.code,
364
+ decimals: typeof decimals === "number" ? Math.min(decimals, MAX_DECIMALS) : DEFAULT_CURRENCY.decimals,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * The budget's display value: `Off` for zero, otherwise the amount in the currency
370
+ * the session prices in. A reader thinks in `¥5`, not in `5`, and in `Off` rather
371
+ * than in `0`.
372
+ */
373
+ function budgetLabel(raw: string, snapshot: SettingsSnapshot, scope: WriteScope): string {
374
+ const amount = Number(raw);
375
+ if (!Number.isFinite(amount) || amount <= 0) return "Off";
376
+ const { code, decimals } = currencyOf(snapshot, scope);
377
+ return `${CURRENCY_SYMBOLS[code]}${amount.toFixed(decimals)}`;
378
+ }
379
+
332
380
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
333
381
  function labelFor(row: SettingDescriptor, raw: string): string {
334
382
  const option = row.options.find((entry) => entry.value === raw);
@@ -428,7 +476,8 @@ export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): Set
428
476
  const options = row.id === "theme" ? themeOptions(snapshot.themes) : row.options;
429
477
  const { value, origin } = row.source === "app" ? appValue(snapshot, row) : byteflyValue(snapshot, row, scope);
430
478
  const view = { ...row, options, value };
431
- return { ...view, valueLabel: labelFor(view, value), origin };
479
+ const valueLabel = row.id === "price.budget" ? budgetLabel(value, snapshot, scope) : labelFor(view, value);
480
+ return { ...view, valueLabel, origin };
432
481
  });
433
482
  }
434
483
 
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The panel's entry model: one flat list of selectable rows per section.
2
+ * The panel's entry model: one flat list of display entries per section, made of
3
+ * group headings interleaved with the selectable rows under them.
3
4
  *
4
- * Extensions interleaves group headings with rows — a source group says where a
5
- * resource came from, which the row cannot — while Settings is flat, in catalogue
6
- * order. Both are built from a model or a set of views, so the component only has
7
- * to keep one cursor per section.
5
+ * Every section groups. A resource list groups by source — where an extension came
6
+ * from is something its row cannot say — and Settings groups by what the
7
+ * preference does, so twelve rows read as four short lists instead of one long
8
+ * one. Both are built from a model or a set of views, so the component only has to
9
+ * keep one cursor per section.
8
10
  *
9
11
  * Pure: the list is rebuilt from a model or a set of views, and navigation is
10
12
  * index arithmetic.
@@ -76,9 +78,25 @@ export function entriesOf(model: PanelModel): PanelEntry[] {
76
78
  }
77
79
 
78
80
  /**
79
- * The Settings entries: one row per view, in catalogue order, with no group
80
- * headings — the list is short enough to read whole.
81
+ * The Settings entries: one heading per group, in catalogue order, then its rows.
82
+ * The group key names the heading so a filter cannot collide a setting id with it.
81
83
  */
82
84
  export function settingEntries(views: readonly SettingView[]): PanelEntry[] {
83
- return views.map((setting) => ({ kind: "setting", setting }));
85
+ const entries: PanelEntry[] = [];
86
+ for (const [group, settings] of groupSettings(views)) {
87
+ entries.push({ kind: "group", key: `setting:${group}`, label: group, count: settings.length });
88
+ for (const setting of settings) entries.push({ kind: "setting", setting });
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ /** The views by their group, in catalogue order of first appearance. */
94
+ function groupSettings(views: readonly SettingView[]): Map<string, SettingView[]> {
95
+ const groups = new Map<string, SettingView[]>();
96
+ for (const view of views) {
97
+ const list = groups.get(view.group) ?? [];
98
+ list.push(view);
99
+ groups.set(view.group, list);
100
+ }
101
+ return groups;
84
102
  }