@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { join } from "node:path";
13
13
  import { CONFIG_DIR_NAME, type QuietStartup } from "@earendil-works/pi-coding-agent";
14
- import { readJson, SHARED_CONFIG_FILE, writeConfigValue } from "../../../lib/config.ts";
14
+ import { readJson, SHARED_CONFIG_FILE, writeConfigValueAt } from "../../../lib/config.ts";
15
15
  import type { SettingsStore, WriteScope } from "../picker/types.ts";
16
16
  import {
17
17
  countSettings,
@@ -22,6 +22,7 @@ import {
22
22
  SETTING_ROWS,
23
23
  settingViews,
24
24
  storedValue,
25
+ textError,
25
26
  validValue,
26
27
  } from "./settings.ts";
27
28
 
@@ -40,7 +41,10 @@ export interface SettingsRuntimeDeps {
40
41
  export interface SettingsSurface {
41
42
  views(scope: WriteScope): SettingView[];
42
43
  count(scope: WriteScope): number;
44
+ /** Cycle an enum or toggle a boolean. */
43
45
  change(scope: WriteScope, id: string): Promise<void>;
46
+ /** Store a value the user typed; `false` when the row refused it. */
47
+ setValue(scope: WriteScope, id: string, raw: string): Promise<boolean>;
44
48
  isDirty(): boolean;
45
49
  }
46
50
 
@@ -81,6 +85,15 @@ function applyAppSetting(store: SettingsStore, row: SettingDescriptor, raw: stri
81
85
  }
82
86
  }
83
87
 
88
+ /** Why `row` cannot be written in `scope`, or `undefined` when it can. */
89
+ function refusal(row: SettingDescriptor, scope: WriteScope, trusted: boolean): string | undefined {
90
+ if (scope === "project" && !trusted) {
91
+ return "Project is not trusted; settings can only be changed in Global scope.";
92
+ }
93
+ if (row.source === "app" && scope !== "global") return `${row.label} is a Global setting.`;
94
+ return undefined;
95
+ }
96
+
84
97
  /** Create the Settings runtime for one screen invocation. */
85
98
  export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntime {
86
99
  let snapshot = readSnapshot(deps);
@@ -91,18 +104,42 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
91
104
  for (const listener of listeners) listener();
92
105
  };
93
106
 
107
+ /**
108
+ * Write one row's value, including the extra leaves the row declares. Throws
109
+ * with the reason a write failed; the caller formats it and leaves the
110
+ * displayed value alone.
111
+ */
112
+ const write = async (scope: WriteScope, row: SettingDescriptor, raw: string): Promise<void> => {
113
+ if (row.source === "app") {
114
+ applyAppSetting(deps.stores.global, row, raw);
115
+ await deps.stores.global.flush();
116
+ const first = deps.stores.global.drainErrors()[0];
117
+ if (first) throw first.error;
118
+ return;
119
+ }
120
+ const config = row.config;
121
+ if (!config) throw new Error(`Setting ${row.id} has no config section`);
122
+ const path = configPath(scope, deps.cwd, deps.agentDir);
123
+ writeConfigValueAt(path, config.section, config.path, storedValue(row, raw));
124
+ for (const leaf of row.also?.(raw) ?? []) writeConfigValueAt(path, leaf.section, leaf.path, leaf.value);
125
+ };
126
+
127
+ /** Take a completed write: re-read the snapshot, mark it dirty, repaint. */
128
+ const committed = (): void => {
129
+ snapshot = readSnapshot(deps);
130
+ dirty = true;
131
+ emit();
132
+ };
133
+
94
134
  return {
95
135
  views: (scope) => settingViews(snapshot, scope),
96
136
  count: (scope) => countSettings(scope),
97
137
  change: async (scope, id) => {
98
138
  const row = SETTING_ROWS.find((entry) => entry.id === id);
99
139
  if (!row) return;
100
- if (scope === "project" && !deps.trusted) {
101
- deps.onError?.("Project is not trusted; settings can only be changed in Global scope.");
102
- return;
103
- }
104
- if (row.source === "app" && scope !== "global") {
105
- deps.onError?.(`${row.label} is a Global setting.`);
140
+ const refused = refusal(row, scope, deps.trusted);
141
+ if (refused !== undefined) {
142
+ deps.onError?.(refused);
106
143
  return;
107
144
  }
108
145
  const current = settingViews(snapshot, scope).find((entry) => entry.id === id);
@@ -112,32 +149,38 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
112
149
  deps.onError?.(`Cannot save ${row.label}: unknown value.`);
113
150
  return;
114
151
  }
115
-
116
152
  try {
117
- if (row.source === "app") {
118
- applyAppSetting(deps.stores.global, row, next.value);
119
- await deps.stores.global.flush();
120
- const errors = deps.stores.global.drainErrors();
121
- const first = errors[0];
122
- if (first) throw first.error;
123
- } else {
124
- const config = row.config;
125
- if (!config) throw new Error(`Setting ${row.id} has no config section`);
126
- writeConfigValue(
127
- configPath(scope, deps.cwd, deps.agentDir),
128
- config.section,
129
- config.key,
130
- storedValue(row, next.value),
131
- );
132
- }
153
+ await write(scope, row, next.value);
133
154
  } catch (error) {
134
155
  deps.onError?.(`Could not save ${row.label}: ${error instanceof Error ? error.message : String(error)}`);
135
156
  return;
136
157
  }
137
-
138
- snapshot = readSnapshot(deps);
139
- dirty = true;
140
- emit();
158
+ committed();
159
+ },
160
+ async setValue(scope, id, raw) {
161
+ const row = SETTING_ROWS.find((entry) => entry.id === id);
162
+ if (!row || row.kind !== "text") {
163
+ deps.onError?.(`${row?.label ?? id} cannot be edited as text.`);
164
+ return false;
165
+ }
166
+ const refused = refusal(row, scope, deps.trusted);
167
+ if (refused !== undefined) {
168
+ deps.onError?.(refused);
169
+ return false;
170
+ }
171
+ const invalid = textError(row, raw);
172
+ if (invalid !== undefined) {
173
+ deps.onError?.(invalid);
174
+ return false;
175
+ }
176
+ try {
177
+ await write(scope, row, raw);
178
+ } catch (error) {
179
+ deps.onError?.(`Could not save ${row.label}: ${error instanceof Error ? error.message : String(error)}`);
180
+ return false;
181
+ }
182
+ committed();
183
+ return true;
141
184
  },
142
185
  isDirty: () => dirty,
143
186
  onChange: (listener) => {
@@ -1,19 +1,29 @@
1
1
  /**
2
2
  * The Settings section's catalogue and value arithmetic.
3
3
  *
4
- * A row is one preference: an app setting from Pi's `settings.json`, or a key
5
- * in a `bytefly.json` section. This module is pure — descriptors, a snapshot to
4
+ * A row is one preference: an app setting from Pi's `settings.json`, or a leaf
5
+ * of a `bytefly.json` section. This module is pure — descriptors, a snapshot to
6
6
  * views, cycling, validation, and grouping — so the screen and the runtime share
7
7
  * one definition and the tests need no host.
8
8
  *
9
- * The catalogue is deliberately curated. The schema walker is a later step;
10
- * five rows prove the row types (boolean checkbox, enum cycle), both scopes,
11
- * and both write paths first.
9
+ * The catalogue covers every scalar preference: booleans, enums, short strings,
10
+ * and — with `doctor.timeoutMs` — a number. Each row states its own rule, so a
11
+ * value the screen accepts is a value the loader keeps. Arrays (`doctor.skip`),
12
+ * the `rates` table, and `price.peak` stay JSON edits: a list, a table, and a
13
+ * nested object are not one row.
12
14
  */
13
15
 
14
- import { configSection } from "../../../lib/config.ts";
16
+ import { configSection, configValueAt } from "../../../lib/config.ts";
15
17
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
16
- import { DEFAULT_CHROME_CONFIG, normalizeChromeConfig } from "../chrome.ts";
18
+ import { DEFAULT_CHROME_CONFIG, isRailGlyph, normalizeChromeConfig } from "../chrome.ts";
19
+ import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
20
+ import {
21
+ CURRENCY_DEFAULTS,
22
+ DEFAULT_BYTEFLY_CONFIG,
23
+ DEFAULT_CURRENCY,
24
+ normalizeByteflyConfig,
25
+ } from "../footer/price/config.ts";
26
+ import type { CurrencyCode } from "../footer/price/types.ts";
17
27
  import { DEFAULT_NOTIFY_CONFIG, normalizeNotifyConfig } from "../notify/config.ts";
18
28
  import type { Settings, WriteScope } from "../picker/types.ts";
19
29
 
@@ -21,7 +31,7 @@ import type { Settings, WriteScope } from "../picker/types.ts";
21
31
  export type SettingSource = "app" | "bytefly";
22
32
 
23
33
  /** The row control a preference uses. */
24
- export type SettingKind = "boolean" | "enum";
34
+ export type SettingKind = "boolean" | "enum" | "text";
25
35
 
26
36
  /** Where the effective value came from. */
27
37
  export type SettingOrigin = "default" | "global" | "project" | "inherited";
@@ -32,14 +42,35 @@ export interface SettingOption {
32
42
  label: string;
33
43
  }
34
44
 
45
+ /** What a text row accepts, checked before a write so the file cannot hold less. */
46
+ export interface TextRule {
47
+ /** Longest accepted value, in the units the loader slices with. */
48
+ maxLength: number;
49
+ /** Why `raw` cannot be stored, or `undefined` when it can. */
50
+ validate?: (raw: string) => string | undefined;
51
+ }
52
+
35
53
  /** One heading in the Settings list. */
36
54
  export interface SettingGroup {
37
55
  key: string;
38
56
  label: string;
39
57
  }
40
58
 
41
- /** A `bytefly.json` location for a row. */
42
- type SettingConfig = { section: "notify"; key: "enabled" } | { section: "chrome"; key: "rail" };
59
+ /** The `bytefly.json` sections the Settings section can read and write. */
60
+ export type ConfigSectionName = "notify" | "chrome" | "doctor" | "price";
61
+
62
+ /** Where a `bytefly.json` row lives: a section, and the path to its leaf. */
63
+ export interface SettingConfig {
64
+ section: ConfigSectionName;
65
+ path: readonly string[];
66
+ }
67
+
68
+ /** One `bytefly.json` leaf a row writes alongside its own. */
69
+ export interface SettingLeaf {
70
+ section: ConfigSectionName;
71
+ path: readonly string[];
72
+ value: unknown;
73
+ }
43
74
 
44
75
  /** The static definition of one preference. */
45
76
  export interface SettingDescriptor {
@@ -51,12 +82,21 @@ export interface SettingDescriptor {
51
82
  description: string;
52
83
  source: SettingSource;
53
84
  kind: SettingKind;
54
- /** Curated values, in cycle order. */
85
+ /** Curated values, in cycle order; empty for a text row. */
55
86
  options: readonly SettingOption[];
87
+ /** For `kind: "text"`: the length limit and any rule the value must satisfy. */
88
+ text?: TextRule;
56
89
  /** The value shown when neither file sets the key. */
57
90
  fallback: string;
58
- /** For `bytefly` rows: the section and key this row edits. */
91
+ /** The JSON type the row stores; string when omitted. */
92
+ json?: "boolean" | "number";
93
+ /** For `bytefly` rows: the section and leaf path this row edits. */
59
94
  config?: SettingConfig;
95
+ /**
96
+ * Extra leaves written with this row's own, for a value that implies another.
97
+ * Kept on the row so the writer stays generic and the rule is stated once.
98
+ */
99
+ also?: (raw: string) => readonly SettingLeaf[];
60
100
  }
61
101
 
62
102
  /** A row with the value read from one scope. */
@@ -86,6 +126,8 @@ export interface GroupedSettings {
86
126
  export const SETTING_GROUPS: readonly SettingGroup[] = [
87
127
  { key: "startup", label: "Startup" },
88
128
  { key: "notifications", label: "Notifications" },
129
+ { key: "diagnostics", label: "Diagnostics" },
130
+ { key: "pricing", label: "Pricing" },
89
131
  { key: "transcript", label: "Transcript" },
90
132
  { key: "terminal", label: "Terminal" },
91
133
  ];
@@ -95,14 +137,49 @@ const ON_OFF: readonly SettingOption[] = [
95
137
  { value: "true", label: "On" },
96
138
  ];
97
139
 
98
- /** Rail glyphs offered in the curated picker; every one is one column. */
99
- const RAIL_OPTIONS: readonly SettingOption[] = [
100
- { value: GLYPHS.divider, label: GLYPHS.divider },
101
- { value: "│", label: "│" },
102
- { value: "┃", label: "┃" },
103
- { value: "▏", label: "▏" },
140
+ /** The notify section's delivery methods, labeled by the surface each reaches. */
141
+ const METHOD_OPTIONS: readonly SettingOption[] = [
142
+ { value: "auto", label: "Auto" },
143
+ { value: "osc777", label: "OSC 777" },
144
+ { value: "osc99", label: "OSC 99" },
145
+ { value: "toast", label: "Windows toast" },
146
+ { value: "osascript", label: "macOS" },
147
+ { value: "notify-send", label: "Linux" },
104
148
  ];
105
149
 
150
+ /** Cost precision 0-4, the range the `price` section clamps `decimals` to. */
151
+ const DECIMALS_OPTIONS: readonly SettingOption[] = [0, 1, 2, 3, 4].map((value) => ({
152
+ value: String(value),
153
+ label: String(value),
154
+ }));
155
+
156
+ /**
157
+ * A text rule for a whole number inside an inclusive range.
158
+ *
159
+ * The bounds are the loader's own (`doctor.timeoutMs` is clamped to 100-30000),
160
+ * so an accepted value is never silently clamped on the next read. `maxLength`
161
+ * only stops a runaway buffer; `validate` is the rule.
162
+ */
163
+ function integerRule(min: number, max: number): TextRule {
164
+ return {
165
+ maxLength: String(max).length + 2,
166
+ validate(raw) {
167
+ const text = raw.trim();
168
+ if (!/^\d+$/.test(text)) return `Use a whole number, ${min} to ${max}.`;
169
+ const value = Number(text);
170
+ if (value < min || value > max) return `Out of range: ${min} to ${max}.`;
171
+ return undefined;
172
+ },
173
+ };
174
+ }
175
+
176
+ /** The rail's rule: the loader drops anything that is not one column. */
177
+ const RAIL_RULE: TextRule = {
178
+ maxLength: 4,
179
+ validate: (raw) =>
180
+ isRailGlyph(raw) ? undefined : "Use exactly one column: a single glyph, with no spaces or control characters.",
181
+ };
182
+
106
183
  /** The v1 catalogue, in screen order. */
107
184
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
108
185
  {
@@ -113,6 +190,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
113
190
  source: "app",
114
191
  kind: "boolean",
115
192
  fallback: "false",
193
+ json: "boolean",
116
194
  options: ON_OFF,
117
195
  },
118
196
  {
@@ -123,19 +201,155 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
123
201
  source: "bytefly",
124
202
  kind: "boolean",
125
203
  fallback: "false",
204
+ json: "boolean",
205
+ options: ON_OFF,
206
+ config: { section: "notify", path: ["enabled"] },
207
+ },
208
+ {
209
+ id: "notify.turnFinished",
210
+ group: "notifications",
211
+ label: "Turn finished",
212
+ description: "Announce it when a full agent run settles.",
213
+ source: "bytefly",
214
+ kind: "boolean",
215
+ fallback: "true",
216
+ json: "boolean",
217
+ options: ON_OFF,
218
+ config: { section: "notify", path: ["turnFinished"] },
219
+ },
220
+ {
221
+ id: "notify.needsInput",
222
+ group: "notifications",
223
+ label: "Needs input",
224
+ description: "Announce it when the agent opens a prompt and waits for an answer.",
225
+ source: "bytefly",
226
+ kind: "boolean",
227
+ fallback: "true",
228
+ json: "boolean",
126
229
  options: ON_OFF,
127
- config: { section: "notify", key: "enabled" },
230
+ config: { section: "notify", path: ["needsInput"] },
231
+ },
232
+ {
233
+ id: "notify.jobFinished",
234
+ group: "notifications",
235
+ label: "Job finished",
236
+ description: "Announce it when a background job or watcher settles.",
237
+ source: "bytefly",
238
+ kind: "boolean",
239
+ fallback: "true",
240
+ json: "boolean",
241
+ options: ON_OFF,
242
+ config: { section: "notify", path: ["jobFinished"] },
243
+ },
244
+ {
245
+ id: "notify.matchFound",
246
+ group: "notifications",
247
+ label: "Watcher matched",
248
+ description: "Announce it when a `watch` whose `wake` is on matches while the agent is idle.",
249
+ source: "bytefly",
250
+ kind: "boolean",
251
+ fallback: "true",
252
+ json: "boolean",
253
+ options: ON_OFF,
254
+ config: { section: "notify", path: ["matchFound"] },
255
+ },
256
+ {
257
+ id: "notify.method",
258
+ group: "notifications",
259
+ label: "Delivery method",
260
+ description: "How a notification is delivered; Auto picks from the terminal, then the platform.",
261
+ source: "bytefly",
262
+ kind: "enum",
263
+ fallback: "auto",
264
+ options: METHOD_OPTIONS,
265
+ config: { section: "notify", path: ["method"] },
266
+ },
267
+ {
268
+ id: "notify.title",
269
+ group: "notifications",
270
+ label: "Notification title",
271
+ description: "Shown as the title of every OS notification; up to 40 characters.",
272
+ source: "bytefly",
273
+ kind: "text",
274
+ fallback: "Pi",
275
+ options: [],
276
+ text: {
277
+ maxLength: 40,
278
+ validate: (raw) => (raw.trim() === "" ? "A title is required." : undefined),
279
+ },
280
+ config: { section: "notify", path: ["title"] },
281
+ },
282
+ {
283
+ id: "doctor.proactive",
284
+ group: "diagnostics",
285
+ label: "Proactive health check",
286
+ description: "Probe enabled extensions at session start and warn about a missing requirement.",
287
+ source: "bytefly",
288
+ kind: "boolean",
289
+ fallback: "true",
290
+ json: "boolean",
291
+ options: ON_OFF,
292
+ config: { section: "doctor", path: ["proactive"] },
293
+ },
294
+ {
295
+ id: "doctor.timeoutMs",
296
+ group: "diagnostics",
297
+ label: "Probe timeout",
298
+ description: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
299
+ source: "bytefly",
300
+ kind: "text",
301
+ fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
302
+ json: "number",
303
+ options: [],
304
+ text: integerRule(100, 30_000),
305
+ config: { section: "doctor", path: ["timeoutMs"] },
306
+ },
307
+ {
308
+ id: "price.currency.code",
309
+ group: "pricing",
310
+ label: "Currency",
311
+ description:
312
+ "CNY prices DeepSeek from its yuan rates; USD from its published dollars, falling back to Pi's recorded cost.",
313
+ source: "bytefly",
314
+ kind: "enum",
315
+ fallback: DEFAULT_CURRENCY.code,
316
+ options: [
317
+ { value: "CNY", label: "CNY (¥)" },
318
+ { value: "USD", label: "USD ($)" },
319
+ ],
320
+ config: { section: "price", path: ["currency", "code"] },
321
+ // Choosing a currency also sets its default precision, so the screen and the
322
+ // footer agree; the precision stays overridable afterwards.
323
+ also: (raw) => {
324
+ const currency = CURRENCY_DEFAULTS[raw as CurrencyCode];
325
+ return currency === undefined
326
+ ? []
327
+ : [{ section: "price", path: ["currency", "decimals"], value: currency.decimals }];
328
+ },
329
+ },
330
+ {
331
+ id: "price.currency.decimals",
332
+ group: "pricing",
333
+ label: "Cost decimals",
334
+ description: "Digits shown after the decimal point in the footer's cost total.",
335
+ source: "bytefly",
336
+ kind: "enum",
337
+ fallback: "2",
338
+ json: "number",
339
+ options: DECIMALS_OPTIONS,
340
+ config: { section: "price", path: ["currency", "decimals"] },
128
341
  },
129
342
  {
130
343
  id: "chrome.rail",
131
344
  group: "transcript",
132
345
  label: "Rail glyph",
133
- description: "The single-column glyph that leads a tool result or notice body.",
346
+ description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
134
347
  source: "bytefly",
135
- kind: "enum",
348
+ kind: "text",
136
349
  fallback: GLYPHS.divider,
137
- options: RAIL_OPTIONS,
138
- config: { section: "chrome", key: "rail" },
350
+ options: [],
351
+ text: RAIL_RULE,
352
+ config: { section: "chrome", path: ["rail"] },
139
353
  },
140
354
  {
141
355
  id: "outputPad",
@@ -145,6 +359,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
145
359
  source: "app",
146
360
  kind: "enum",
147
361
  fallback: "1",
362
+ json: "number",
148
363
  options: [
149
364
  { value: "0", label: "0" },
150
365
  { value: "1", label: "1" },
@@ -158,6 +373,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
158
373
  source: "app",
159
374
  kind: "boolean",
160
375
  fallback: "false",
376
+ json: "boolean",
161
377
  options: ON_OFF,
162
378
  },
163
379
  ];
@@ -177,6 +393,21 @@ export function validValue(row: SettingDescriptor, raw: string): boolean {
177
393
  return row.options.some((option) => option.value === raw);
178
394
  }
179
395
 
396
+ /**
397
+ * Why a text row cannot store `raw`, or `undefined` when it can.
398
+ *
399
+ * The limits mirror the loaders (`notify.title` is sliced to 40, the currency
400
+ * symbol to 4) so the editor cannot save a value the next read shortens.
401
+ */
402
+ export function textError(row: SettingDescriptor, raw: string): string | undefined {
403
+ const rule = row.text;
404
+ if (!rule) return `${row.label} is not a text setting.`;
405
+ if (raw.length > rule.maxLength) {
406
+ return `Too long: ${raw.length} characters, the limit is ${rule.maxLength}.`;
407
+ }
408
+ return rule.validate?.(raw);
409
+ }
410
+
180
411
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
181
412
  function labelFor(row: SettingDescriptor, raw: string): string {
182
413
  return row.options.find((option) => option.value === raw)?.label ?? raw;
@@ -204,15 +435,35 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
204
435
  }
205
436
  }
206
437
 
207
- /** Whether a project section sets `key`, so the origin is truthful. */
438
+ /** Whether a document states this row's leaf, so the origin is truthful. */
439
+ function statesConfig(config: SettingConfig, document: Record<string, unknown>): boolean {
440
+ return configValueAt(document, [config.section, ...config.path]) !== undefined;
441
+ }
442
+
443
+ /** Where the effective value came from: the project file, the personal one, or nothing. */
208
444
  function originFor(config: SettingConfig, snapshot: SettingsSnapshot, scope: WriteScope): SettingOrigin {
209
- const globalRaw = configSection(snapshot.global, config.section);
210
- const projectRaw = scope === "project" ? configSection(snapshot.project, config.section) : undefined;
211
- if (projectRaw && config.key in projectRaw) return "project";
212
- if (scope === "project") return "inherited";
213
- return globalRaw && config.key in globalRaw ? "global" : "default";
445
+ if (scope === "project") return statesConfig(config, snapshot.project) ? "project" : "inherited";
446
+ return statesConfig(config, snapshot.global) ? "global" : "default";
214
447
  }
215
448
 
449
+ /** Read one leaf of a `bytefly.json` section: project over global over defaults. */
450
+ type SectionReader = (
451
+ globalRaw: Record<string, unknown> | undefined,
452
+ projectRaw: Record<string, unknown> | undefined,
453
+ path: readonly string[],
454
+ ) => unknown;
455
+
456
+ const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
457
+ notify: (globalRaw, projectRaw, path) =>
458
+ configValueAt(normalizeNotifyConfig(projectRaw, normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG)), path),
459
+ chrome: (globalRaw, projectRaw, path) =>
460
+ configValueAt(normalizeChromeConfig(projectRaw, normalizeChromeConfig(globalRaw, DEFAULT_CHROME_CONFIG)), path),
461
+ doctor: (globalRaw, projectRaw, path) =>
462
+ configValueAt(normalizeDoctorConfig(projectRaw, normalizeDoctorConfig(globalRaw, DEFAULT_DOCTOR_CONFIG)), path),
463
+ price: (globalRaw, projectRaw, path) =>
464
+ configValueAt(normalizeByteflyConfig(projectRaw, normalizeByteflyConfig(globalRaw, DEFAULT_BYTEFLY_CONFIG)), path),
465
+ };
466
+
216
467
  /** Read a `bytefly.json` preference, normalized over the global section. */
217
468
  function byteflyValue(
218
469
  snapshot: SettingsSnapshot,
@@ -223,15 +474,11 @@ function byteflyValue(
223
474
  if (!config) throw new Error(`Setting ${row.id} has no config section`);
224
475
  const globalRaw = configSection(snapshot.global, config.section);
225
476
  const projectRaw = scope === "project" ? configSection(snapshot.project, config.section) : undefined;
226
- const origin = originFor(config, snapshot, scope);
227
- if (config.section === "notify") {
228
- const global = normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG);
229
- const effective = normalizeNotifyConfig(projectRaw, global);
230
- return { value: effective.enabled ? "true" : "false", origin };
231
- }
232
- const global = normalizeChromeConfig(globalRaw, DEFAULT_CHROME_CONFIG);
233
- const effective = normalizeChromeConfig(projectRaw, global);
234
- return { value: effective.rail, origin };
477
+ const leaf = SECTION_READERS[config.section](globalRaw, projectRaw, config.path);
478
+ return {
479
+ value: leaf === undefined ? row.fallback : String(leaf),
480
+ origin: originFor(config, snapshot, scope),
481
+ };
235
482
  }
236
483
 
237
484
  /** Build the rows for `scope` from a snapshot. */
@@ -244,23 +491,21 @@ export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): Set
244
491
 
245
492
  /** The next curated value, wrapping; an uncurated value starts the cycle. */
246
493
  export function nextValue(row: SettingDescriptor, current: string): SettingOption {
494
+ // A text row has no cycle: the value it holds is the value it keeps.
495
+ if (row.options.length === 0) return { value: current, label: current };
247
496
  const index = row.options.findIndex((option) => option.value === current);
248
497
  return row.options[(index + 1) % row.options.length] as SettingOption;
249
498
  }
250
499
 
251
- /** The JSON value a raw option stores, per row. */
500
+ /** The JSON value a raw option stores, per the row's declared JSON type. */
252
501
  export function storedValue(row: SettingDescriptor, raw: string): unknown {
253
- switch (row.id) {
254
- case "outputPad":
255
- return raw === "1" ? 1 : 0;
256
- case "byteflyUi":
257
- case "notify.enabled":
258
- case "showHardwareCursor":
502
+ switch (row.json) {
503
+ case "boolean":
259
504
  return raw === "true";
260
- case "chrome.rail":
261
- return raw;
505
+ case "number":
506
+ return Number(raw);
262
507
  default:
263
- throw new Error(`Unknown setting ${row.id}`);
508
+ return raw;
264
509
  }
265
510
  }
266
511
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The panel's entry model: a flat list where group headers and selectable rows
3
+ * interleave, shared by both sections.
4
+ *
5
+ * Pure: the list is rebuilt from a model or a set of views, and navigation is
6
+ * index arithmetic, so the component only has to keep one cursor per section.
7
+ */
8
+
9
+ import { groupViews, type SettingView } from "../settings.ts";
10
+ import type { PanelModel, PanelRow } from "../types.ts";
11
+
12
+ /** A flat display entry: a group header or a selectable row. */
13
+ export type PanelEntry =
14
+ | { kind: "group"; key: string; label: string; count: number }
15
+ | { kind: "row"; row: PanelRow }
16
+ | { kind: "setting"; setting: SettingView };
17
+
18
+ /** A selectable entry's stable key, or `undefined` for a group header. */
19
+ export function entryKey(entry: PanelEntry | undefined): string | undefined {
20
+ if (entry?.kind === "row") return entry.row.key;
21
+ if (entry?.kind === "setting") return entry.setting.id;
22
+ return undefined;
23
+ }
24
+
25
+ /** Whether an entry can take the focus. */
26
+ export function isSelectable(entry: PanelEntry | undefined): boolean {
27
+ return entryKey(entry) !== undefined;
28
+ }
29
+
30
+ /** The next selectable entry from `from` in `direction`, or `undefined`. */
31
+ export function nextRow(entries: PanelEntry[], from: number, direction: -1 | 1): number | undefined {
32
+ for (let index = from + direction; index >= 0 && index < entries.length; index += direction) {
33
+ if (isSelectable(entries[index])) return index;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** Interleave the extension model's groups and rows into one display list. */
39
+ export function entriesOf(model: PanelModel): PanelEntry[] {
40
+ const entries: PanelEntry[] = [];
41
+ for (const group of model.groups) {
42
+ entries.push({ kind: "group", key: group.key, label: group.label, count: group.count });
43
+ for (const row of model.rows) {
44
+ if (row.group === group.key) entries.push({ kind: "row", row });
45
+ }
46
+ }
47
+ return entries;
48
+ }
49
+
50
+ /** Interleave the settings views into the same group/row display list. */
51
+ export function settingEntries(views: readonly SettingView[]): PanelEntry[] {
52
+ const entries: PanelEntry[] = [];
53
+ for (const group of groupViews(views)) {
54
+ entries.push({ kind: "group", key: group.key, label: group.label, count: group.views.length });
55
+ for (const view of group.views) entries.push({ kind: "setting", setting: view });
56
+ }
57
+ return entries;
58
+ }