@gavin-hu/my-pi-agent 0.11.3 → 0.12.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 (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -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,28 @@
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 is deliberately curated: one proven row family at a time. It
10
+ * covers scalars (a boolean checkbox, an enum cycle, a short string with its own
11
+ * rule), both scopes, both write paths, and — with `price.currency.decimals` —
12
+ * one nested key. Arrays, nested tariffs, and credentials are still JSON edits.
12
13
  */
13
14
 
14
- import { configSection } from "../../../lib/config.ts";
15
+ import { configSection, configValueAt } from "../../../lib/config.ts";
15
16
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
16
17
  import { DEFAULT_CHROME_CONFIG, normalizeChromeConfig } from "../chrome.ts";
18
+ import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
19
+ import {
20
+ CURRENCY_DEFAULTS,
21
+ DEFAULT_BYTEFLY_CONFIG,
22
+ DEFAULT_CURRENCY,
23
+ normalizeByteflyConfig,
24
+ } from "../footer/price/config.ts";
25
+ import type { CurrencyCode } from "../footer/price/types.ts";
17
26
  import { DEFAULT_NOTIFY_CONFIG, normalizeNotifyConfig } from "../notify/config.ts";
18
27
  import type { Settings, WriteScope } from "../picker/types.ts";
19
28
 
@@ -21,7 +30,7 @@ import type { Settings, WriteScope } from "../picker/types.ts";
21
30
  export type SettingSource = "app" | "bytefly";
22
31
 
23
32
  /** The row control a preference uses. */
24
- export type SettingKind = "boolean" | "enum";
33
+ export type SettingKind = "boolean" | "enum" | "text";
25
34
 
26
35
  /** Where the effective value came from. */
27
36
  export type SettingOrigin = "default" | "global" | "project" | "inherited";
@@ -32,14 +41,35 @@ export interface SettingOption {
32
41
  label: string;
33
42
  }
34
43
 
44
+ /** What a text row accepts, checked before a write so the file cannot hold less. */
45
+ export interface TextRule {
46
+ /** Longest accepted value, in the units the loader slices with. */
47
+ maxLength: number;
48
+ /** Why `raw` cannot be stored, or `undefined` when it can. */
49
+ validate?: (raw: string) => string | undefined;
50
+ }
51
+
35
52
  /** One heading in the Settings list. */
36
53
  export interface SettingGroup {
37
54
  key: string;
38
55
  label: string;
39
56
  }
40
57
 
41
- /** A `bytefly.json` location for a row. */
42
- type SettingConfig = { section: "notify"; key: "enabled" } | { section: "chrome"; key: "rail" };
58
+ /** The `bytefly.json` sections the Settings section can read and write. */
59
+ export type ConfigSectionName = "notify" | "chrome" | "doctor" | "price";
60
+
61
+ /** Where a `bytefly.json` row lives: a section, and the path to its leaf. */
62
+ export interface SettingConfig {
63
+ section: ConfigSectionName;
64
+ path: readonly string[];
65
+ }
66
+
67
+ /** One `bytefly.json` leaf a row writes alongside its own. */
68
+ export interface SettingLeaf {
69
+ section: ConfigSectionName;
70
+ path: readonly string[];
71
+ value: unknown;
72
+ }
43
73
 
44
74
  /** The static definition of one preference. */
45
75
  export interface SettingDescriptor {
@@ -51,12 +81,21 @@ export interface SettingDescriptor {
51
81
  description: string;
52
82
  source: SettingSource;
53
83
  kind: SettingKind;
54
- /** Curated values, in cycle order. */
84
+ /** Curated values, in cycle order; empty for a text row. */
55
85
  options: readonly SettingOption[];
86
+ /** For `kind: "text"`: the length limit and any rule the value must satisfy. */
87
+ text?: TextRule;
56
88
  /** The value shown when neither file sets the key. */
57
89
  fallback: string;
58
- /** For `bytefly` rows: the section and key this row edits. */
90
+ /** The JSON type the row stores; string when omitted. */
91
+ json?: "boolean" | "number";
92
+ /** For `bytefly` rows: the section and leaf path this row edits. */
59
93
  config?: SettingConfig;
94
+ /**
95
+ * Extra leaves written with this row's own, for a value that implies another.
96
+ * Kept on the row so the writer stays generic and the rule is stated once.
97
+ */
98
+ also?: (raw: string) => readonly SettingLeaf[];
60
99
  }
61
100
 
62
101
  /** A row with the value read from one scope. */
@@ -86,6 +125,8 @@ export interface GroupedSettings {
86
125
  export const SETTING_GROUPS: readonly SettingGroup[] = [
87
126
  { key: "startup", label: "Startup" },
88
127
  { key: "notifications", label: "Notifications" },
128
+ { key: "diagnostics", label: "Diagnostics" },
129
+ { key: "pricing", label: "Pricing" },
89
130
  { key: "transcript", label: "Transcript" },
90
131
  { key: "terminal", label: "Terminal" },
91
132
  ];
@@ -103,6 +144,22 @@ const RAIL_OPTIONS: readonly SettingOption[] = [
103
144
  { value: "▏", label: "▏" },
104
145
  ];
105
146
 
147
+ /** The notify section's delivery methods, labeled by the surface each reaches. */
148
+ const METHOD_OPTIONS: readonly SettingOption[] = [
149
+ { value: "auto", label: "Auto" },
150
+ { value: "osc777", label: "OSC 777" },
151
+ { value: "osc99", label: "OSC 99" },
152
+ { value: "toast", label: "Windows toast" },
153
+ { value: "osascript", label: "macOS" },
154
+ { value: "notify-send", label: "Linux" },
155
+ ];
156
+
157
+ /** Cost precision 0-4, the range the `price` section clamps `decimals` to. */
158
+ const DECIMALS_OPTIONS: readonly SettingOption[] = [0, 1, 2, 3, 4].map((value) => ({
159
+ value: String(value),
160
+ label: String(value),
161
+ }));
162
+
106
163
  /** The v1 catalogue, in screen order. */
107
164
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
108
165
  {
@@ -113,6 +170,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
113
170
  source: "app",
114
171
  kind: "boolean",
115
172
  fallback: "false",
173
+ json: "boolean",
116
174
  options: ON_OFF,
117
175
  },
118
176
  {
@@ -123,8 +181,82 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
123
181
  source: "bytefly",
124
182
  kind: "boolean",
125
183
  fallback: "false",
184
+ json: "boolean",
185
+ options: ON_OFF,
186
+ config: { section: "notify", path: ["enabled"] },
187
+ },
188
+ {
189
+ id: "notify.method",
190
+ group: "notifications",
191
+ label: "Delivery method",
192
+ description: "How a notification is delivered; Auto picks from the terminal, then the platform.",
193
+ source: "bytefly",
194
+ kind: "enum",
195
+ fallback: "auto",
196
+ options: METHOD_OPTIONS,
197
+ config: { section: "notify", path: ["method"] },
198
+ },
199
+ {
200
+ id: "notify.title",
201
+ group: "notifications",
202
+ label: "Notification title",
203
+ description: "Shown as the title of every OS notification; up to 40 characters.",
204
+ source: "bytefly",
205
+ kind: "text",
206
+ fallback: "Pi",
207
+ options: [],
208
+ text: {
209
+ maxLength: 40,
210
+ validate: (raw) => (raw.trim() === "" ? "A title is required." : undefined),
211
+ },
212
+ config: { section: "notify", path: ["title"] },
213
+ },
214
+ {
215
+ id: "doctor.proactive",
216
+ group: "diagnostics",
217
+ label: "Proactive health check",
218
+ description: "Probe enabled extensions at session start and warn about a missing requirement.",
219
+ source: "bytefly",
220
+ kind: "boolean",
221
+ fallback: "true",
222
+ json: "boolean",
126
223
  options: ON_OFF,
127
- config: { section: "notify", key: "enabled" },
224
+ config: { section: "doctor", path: ["proactive"] },
225
+ },
226
+ {
227
+ id: "price.currency.code",
228
+ group: "pricing",
229
+ label: "Currency",
230
+ description:
231
+ "CNY prices DeepSeek from its yuan rates; USD from its published dollars, falling back to Pi's recorded cost.",
232
+ source: "bytefly",
233
+ kind: "enum",
234
+ fallback: DEFAULT_CURRENCY.code,
235
+ options: [
236
+ { value: "CNY", label: "CNY (¥)" },
237
+ { value: "USD", label: "USD ($)" },
238
+ ],
239
+ config: { section: "price", path: ["currency", "code"] },
240
+ // Choosing a currency also sets its default precision, so the screen and the
241
+ // footer agree; the precision stays overridable afterwards.
242
+ also: (raw) => {
243
+ const currency = CURRENCY_DEFAULTS[raw as CurrencyCode];
244
+ return currency === undefined
245
+ ? []
246
+ : [{ section: "price", path: ["currency", "decimals"], value: currency.decimals }];
247
+ },
248
+ },
249
+ {
250
+ id: "price.currency.decimals",
251
+ group: "pricing",
252
+ label: "Cost decimals",
253
+ description: "Digits shown after the decimal point in the footer's cost total.",
254
+ source: "bytefly",
255
+ kind: "enum",
256
+ fallback: "2",
257
+ json: "number",
258
+ options: DECIMALS_OPTIONS,
259
+ config: { section: "price", path: ["currency", "decimals"] },
128
260
  },
129
261
  {
130
262
  id: "chrome.rail",
@@ -135,7 +267,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
135
267
  kind: "enum",
136
268
  fallback: GLYPHS.divider,
137
269
  options: RAIL_OPTIONS,
138
- config: { section: "chrome", key: "rail" },
270
+ config: { section: "chrome", path: ["rail"] },
139
271
  },
140
272
  {
141
273
  id: "outputPad",
@@ -145,6 +277,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
145
277
  source: "app",
146
278
  kind: "enum",
147
279
  fallback: "1",
280
+ json: "number",
148
281
  options: [
149
282
  { value: "0", label: "0" },
150
283
  { value: "1", label: "1" },
@@ -158,6 +291,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
158
291
  source: "app",
159
292
  kind: "boolean",
160
293
  fallback: "false",
294
+ json: "boolean",
161
295
  options: ON_OFF,
162
296
  },
163
297
  ];
@@ -177,6 +311,21 @@ export function validValue(row: SettingDescriptor, raw: string): boolean {
177
311
  return row.options.some((option) => option.value === raw);
178
312
  }
179
313
 
314
+ /**
315
+ * Why a text row cannot store `raw`, or `undefined` when it can.
316
+ *
317
+ * The limits mirror the loaders (`notify.title` is sliced to 40, the currency
318
+ * symbol to 4) so the editor cannot save a value the next read shortens.
319
+ */
320
+ export function textError(row: SettingDescriptor, raw: string): string | undefined {
321
+ const rule = row.text;
322
+ if (!rule) return `${row.label} is not a text setting.`;
323
+ if (raw.length > rule.maxLength) {
324
+ return `Too long: ${raw.length} characters, the limit is ${rule.maxLength}.`;
325
+ }
326
+ return rule.validate?.(raw);
327
+ }
328
+
180
329
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
181
330
  function labelFor(row: SettingDescriptor, raw: string): string {
182
331
  return row.options.find((option) => option.value === raw)?.label ?? raw;
@@ -204,15 +353,35 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
204
353
  }
205
354
  }
206
355
 
207
- /** Whether a project section sets `key`, so the origin is truthful. */
356
+ /** Whether a document states this row's leaf, so the origin is truthful. */
357
+ function statesConfig(config: SettingConfig, document: Record<string, unknown>): boolean {
358
+ return configValueAt(document, [config.section, ...config.path]) !== undefined;
359
+ }
360
+
361
+ /** Where the effective value came from: the project file, the personal one, or nothing. */
208
362
  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";
363
+ if (scope === "project") return statesConfig(config, snapshot.project) ? "project" : "inherited";
364
+ return statesConfig(config, snapshot.global) ? "global" : "default";
214
365
  }
215
366
 
367
+ /** Read one leaf of a `bytefly.json` section: project over global over defaults. */
368
+ type SectionReader = (
369
+ globalRaw: Record<string, unknown> | undefined,
370
+ projectRaw: Record<string, unknown> | undefined,
371
+ path: readonly string[],
372
+ ) => unknown;
373
+
374
+ const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
375
+ notify: (globalRaw, projectRaw, path) =>
376
+ configValueAt(normalizeNotifyConfig(projectRaw, normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG)), path),
377
+ chrome: (globalRaw, projectRaw, path) =>
378
+ configValueAt(normalizeChromeConfig(projectRaw, normalizeChromeConfig(globalRaw, DEFAULT_CHROME_CONFIG)), path),
379
+ doctor: (globalRaw, projectRaw, path) =>
380
+ configValueAt(normalizeDoctorConfig(projectRaw, normalizeDoctorConfig(globalRaw, DEFAULT_DOCTOR_CONFIG)), path),
381
+ price: (globalRaw, projectRaw, path) =>
382
+ configValueAt(normalizeByteflyConfig(projectRaw, normalizeByteflyConfig(globalRaw, DEFAULT_BYTEFLY_CONFIG)), path),
383
+ };
384
+
216
385
  /** Read a `bytefly.json` preference, normalized over the global section. */
217
386
  function byteflyValue(
218
387
  snapshot: SettingsSnapshot,
@@ -223,15 +392,11 @@ function byteflyValue(
223
392
  if (!config) throw new Error(`Setting ${row.id} has no config section`);
224
393
  const globalRaw = configSection(snapshot.global, config.section);
225
394
  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 };
395
+ const leaf = SECTION_READERS[config.section](globalRaw, projectRaw, config.path);
396
+ return {
397
+ value: leaf === undefined ? row.fallback : String(leaf),
398
+ origin: originFor(config, snapshot, scope),
399
+ };
235
400
  }
236
401
 
237
402
  /** Build the rows for `scope` from a snapshot. */
@@ -244,23 +409,21 @@ export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): Set
244
409
 
245
410
  /** The next curated value, wrapping; an uncurated value starts the cycle. */
246
411
  export function nextValue(row: SettingDescriptor, current: string): SettingOption {
412
+ // A text row has no cycle: the value it holds is the value it keeps.
413
+ if (row.options.length === 0) return { value: current, label: current };
247
414
  const index = row.options.findIndex((option) => option.value === current);
248
415
  return row.options[(index + 1) % row.options.length] as SettingOption;
249
416
  }
250
417
 
251
- /** The JSON value a raw option stores, per row. */
418
+ /** The JSON value a raw option stores, per the row's declared JSON type. */
252
419
  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":
420
+ switch (row.json) {
421
+ case "boolean":
259
422
  return raw === "true";
260
- case "chrome.rail":
261
- return raw;
423
+ case "number":
424
+ return Number(raw);
262
425
  default:
263
- throw new Error(`Unknown setting ${row.id}`);
426
+ return raw;
264
427
  }
265
428
  }
266
429
 
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The Extensions section: its summary line, one row per resolved extension, and
3
+ * the detail pane for the focused row.
4
+ *
5
+ * Pure functions over the merged model — the component owns the cursor, the
6
+ * filter, and the probe state, and passes them in.
7
+ */
8
+
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { sanitizeLine } from "../../../../lib/format.ts";
11
+ import { clip } from "../../../../lib/tui/columns.ts";
12
+ import { checkbox, listRow } from "../../../../lib/tui/rows.ts";
13
+ import { paint } from "../../../../lib/tui/tokens.ts";
14
+ import { detailLines as reportDetailLines } from "../../doctor/format.ts";
15
+ import { rowBadge, summarySegments, type PanelScope } from "../format.ts";
16
+ import type { PanelModel, PanelRow } from "../types.ts";
17
+ import { countsLine, savedLine } from "./frame.ts";
18
+
19
+ /** The section's hits, its scope, and how many need attention. */
20
+ export function extensionSummary(
21
+ theme: Theme,
22
+ width: number,
23
+ model: PanelModel,
24
+ scope: PanelScope,
25
+ filter: string | undefined,
26
+ ): string {
27
+ return countsLine(theme, width, summarySegments(model, scope, { filter, compact: width < 72 }));
28
+ }
29
+
30
+ /** One extension row: its checkbox, health glyph, name, and verdict. */
31
+ export function extensionRow(theme: Theme, width: number, row: PanelRow, selected: boolean): string {
32
+ const badge = rowBadge(row);
33
+ return listRow(theme, width, {
34
+ selected,
35
+ // A reported-only row has no enable pattern, but still reserves the box
36
+ // column so the list stays aligned.
37
+ leading: row.item ? checkbox(theme, row.item.enabled) : " ",
38
+ glyph: badge.glyph ?? " ",
39
+ glyphRole: badge.role,
40
+ label: sanitizeLine(row.label),
41
+ labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
42
+ trailing: badge.text,
43
+ // The glyph carries the status colour; the word stays quiet unless it is a
44
+ // problem, so a clean row does not shout.
45
+ trailingRole: badge.role === "bad" || badge.role === "warn" ? badge.role : "faint",
46
+ });
47
+ }
48
+
49
+ /** The focused extension's label, findings, and the pending-changes note. */
50
+ export function extensionDetail(
51
+ theme: Theme,
52
+ width: number,
53
+ row: PanelRow | undefined,
54
+ budget: number,
55
+ expanded: boolean,
56
+ dirty: boolean,
57
+ ): string[] {
58
+ const lines: string[] = [];
59
+ const badge = row ? rowBadge(row) : undefined;
60
+ if (row && badge?.text !== undefined) {
61
+ const head = `${paint(theme, "meta", sanitizeLine(row.label))} ${paint(theme, "faint", "—")} ${paint(theme, badge.role, badge.text)}`;
62
+ lines.push(clip(` ${head}`, width));
63
+ } else if (row?.item) {
64
+ lines.push(clip(` ${paint(theme, "meta", sanitizeLine(row.item.path))}`, width));
65
+ }
66
+ if (row?.report && budget > 0) {
67
+ lines.push(...reportDetailLines(row.report, theme, width, budget, expanded));
68
+ }
69
+ lines.push(...savedLine(theme, width, dirty));
70
+ return lines;
71
+ }
72
+
73
+ /** The section's key hints, in the order they drop as the width runs out. */
74
+ export function extensionHints(options: { expanded: boolean; refreshing: boolean; projectScope: boolean }): string[] {
75
+ return [
76
+ "Esc close",
77
+ "←/→",
78
+ "Space toggle",
79
+ options.expanded ? "Enter collapse" : "Enter detail",
80
+ "Tab scope",
81
+ // Only Project scope can hold an override, so the key is not offered elsewhere.
82
+ ...(options.projectScope ? ["x inherit"] : []),
83
+ options.refreshing ? "refreshing…" : "r refresh",
84
+ "↑/↓ select",
85
+ ];
86
+ }