@demigodmode/pi-web-agent 1.1.0 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,20 @@ The format is intentionally simple and release-oriented.
18
18
  ### Breaking
19
19
  - None.
20
20
 
21
+ ## [1.2.0] - 2026-06-01
22
+ ### Added
23
+ - Added backend provider and fallback editing to `/web-agent settings`.
24
+ - Added interactive URL prompts for SearXNG and Firecrawl backend setup.
25
+
26
+ ### Changed
27
+ - Nothing yet.
28
+
29
+ ### Fixed
30
+ - Nothing yet.
31
+
32
+ ### Breaking
33
+ - None.
34
+
21
35
  ## [1.1.0] - 2026-05-25
22
36
  ### Added
23
37
  - Added explicit opt-in fallback from SearXNG to DuckDuckGo and Firecrawl to HTTP.
package/README.md CHANGED
@@ -109,7 +109,17 @@ Example:
109
109
  }
110
110
  ```
111
111
 
112
- Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local browser headless fallback. If you already run SearXNG or Firecrawl, see the self-hosted backend guide:
112
+ Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local browser headless fallback.
113
+
114
+ Backend settings can be changed from:
115
+
116
+ ```text
117
+ /web-agent settings
118
+ ```
119
+
120
+ Choose **Backends** to edit search/fetch providers, fallback behavior, and SearXNG or Firecrawl base URLs interactively. Firecrawl API keys should stay in environment variables rather than being written into config files.
121
+
122
+ If you already run SearXNG or Firecrawl, see the self-hosted backend guide:
113
123
 
114
124
  - https://demigodmode.github.io/pi-web-agent/self-hosted-backends
115
125
 
@@ -1,4 +1,4 @@
1
- import { type BackendConfig } from '../backends/config.js';
1
+ import { type BackendConfig, type BackendConfigOverride } from '../backends/config.js';
2
2
  import { type ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
3
  import { loadPresentationConfigLayers, type LoadedPresentationConfig } from '../presentation/config-store.js';
4
4
  import { type BrowserResolutionResult } from '../fetch/browser-resolution.js';
@@ -6,6 +6,7 @@ import type { PresentationConfig, PresentationConfigOverride, PresentationScope
6
6
  type CommandDeps = {
7
7
  load?: () => ReturnType<typeof loadPresentationConfigLayers>;
8
8
  save?: (scope: PresentationScope, config: PresentationConfigOverride) => Promise<void>;
9
+ saveBackends?: (scope: PresentationScope, config: BackendConfigOverride) => Promise<void>;
9
10
  reset?: (scope: PresentationScope) => Promise<void>;
10
11
  resolveBrowser?: () => Promise<BrowserResolutionResult>;
11
12
  runtime?: {
@@ -20,13 +21,25 @@ type CommandDeps = {
20
21
  export type SettingsDraftState = {
21
22
  scope: PresentationScope;
22
23
  drafts: Record<PresentationScope, PresentationConfig>;
24
+ backendDrafts: Record<PresentationScope, BackendConfig>;
23
25
  config: PresentationConfig;
26
+ backends: BackendConfig;
27
+ };
28
+ export declare function validateBackendUrl(value: string): {
29
+ ok: true;
30
+ value: string;
31
+ } | {
32
+ ok: false;
33
+ message: string;
24
34
  };
25
35
  export declare function getInheritedConfigForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
26
36
  export declare function getScopeDisplayConfig(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
37
+ export declare function getInheritedBackendsForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): BackendConfig;
38
+ export declare function getScopeDisplayBackends(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): BackendConfig;
27
39
  export declare function createSettingsDraftState(loaded: Awaited<LoadedPresentationConfig>, initialScope: PresentationScope): SettingsDraftState;
28
40
  export declare function applySettingsValue(state: SettingsDraftState, id: string, newValue: string): SettingsDraftState;
29
41
  export declare function collapsePresentationConfigToOverride(config: PresentationConfig, inheritedConfig: PresentationConfig): PresentationConfigOverride;
42
+ export declare function collapseBackendConfigToOverride(config: BackendConfig, inheritedConfig: BackendConfig): BackendConfigOverride;
30
43
  export declare function handleSettingsShortcut(data: string): {
31
44
  action: 'cancel' | 'reset' | 'save';
32
45
  } | undefined;
@@ -1,9 +1,9 @@
1
- import { DEFAULT_BACKEND_CONFIG, validateBackendConfig } from '../backends/config.js';
1
+ import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig } from '../backends/config.js';
2
2
  import { checkBackendHealth } from '../backends/doctor.js';
3
3
  import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
4
4
  import { Container, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
5
5
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
6
- import { loadPresentationConfigLayers, resetPresentationConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
6
+ import { loadPresentationConfigLayers, resetPresentationConfigScope, saveBackendConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
7
7
  import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
8
8
  import { getLatestChangelogEntry } from '../changelog-notice.js';
9
9
  const PRESENTATION_TOOL_NAMES = ['web_explore'];
@@ -16,6 +16,34 @@ function clonePresentationConfig(config) {
16
16
  tools: { ...config.tools }
17
17
  };
18
18
  }
19
+ function cloneBackendConfig(config) {
20
+ return {
21
+ search: {
22
+ ...config.search,
23
+ options: config.search.options ? { ...config.search.options } : undefined
24
+ },
25
+ fetch: {
26
+ ...config.fetch,
27
+ options: config.fetch.options ? { ...config.fetch.options } : undefined
28
+ },
29
+ headless: { ...config.headless }
30
+ };
31
+ }
32
+ function sameJson(left, right) {
33
+ return JSON.stringify(left) === JSON.stringify(right);
34
+ }
35
+ export function validateBackendUrl(value) {
36
+ try {
37
+ const url = new URL(value.trim());
38
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
39
+ return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
40
+ }
41
+ return { ok: true, value: url.toString().replace(/\/$/, '') };
42
+ }
43
+ catch {
44
+ return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
45
+ }
46
+ }
19
47
  async function defaultCheckTypebox() {
20
48
  try {
21
49
  await import('typebox');
@@ -62,7 +90,7 @@ function formatConfigSummary(config) {
62
90
  }
63
91
  return lines.join('\n');
64
92
  }
65
- function buildSettingsItems(scope, config) {
93
+ function buildPresentationSettingsItems(scope, config) {
66
94
  return [
67
95
  {
68
96
  id: 'scope',
@@ -84,6 +112,58 @@ function buildSettingsItems(scope, config) {
84
112
  }))
85
113
  ];
86
114
  }
115
+ function buildBackendSettingsItems(scope, backends) {
116
+ return [
117
+ {
118
+ id: 'scope',
119
+ label: 'Write scope',
120
+ currentValue: scope,
121
+ values: ['project', 'global']
122
+ },
123
+ {
124
+ id: 'backend:search:provider',
125
+ label: 'Search backend',
126
+ currentValue: backends.search.provider,
127
+ values: ['duckduckgo', 'searxng']
128
+ },
129
+ {
130
+ id: 'backend:search:baseUrl',
131
+ label: 'SearXNG URL',
132
+ currentValue: backends.search.baseUrl ?? 'not set',
133
+ values: ['edit']
134
+ },
135
+ {
136
+ id: 'backend:search:fallback',
137
+ label: 'SearXNG fallback',
138
+ currentValue: backends.search.provider === 'searxng' ? backends.search.fallback ?? 'off' : 'off',
139
+ values: backends.search.provider === 'searxng' ? ['off', 'duckduckgo'] : ['off']
140
+ },
141
+ {
142
+ id: 'backend:fetch:provider',
143
+ label: 'Fetch backend',
144
+ currentValue: backends.fetch.provider,
145
+ values: ['http', 'firecrawl']
146
+ },
147
+ {
148
+ id: 'backend:fetch:baseUrl',
149
+ label: 'Firecrawl URL',
150
+ currentValue: backends.fetch.baseUrl ?? 'not set',
151
+ values: ['edit']
152
+ },
153
+ {
154
+ id: 'backend:fetch:fallback',
155
+ label: 'Firecrawl fallback',
156
+ currentValue: backends.fetch.provider === 'firecrawl' ? backends.fetch.fallback ?? 'off' : 'off',
157
+ values: backends.fetch.provider === 'firecrawl' ? ['off', 'http'] : ['off']
158
+ },
159
+ {
160
+ id: 'backend:secret:firecrawl',
161
+ label: 'Firecrawl API key',
162
+ currentValue: 'env var',
163
+ values: ['env var']
164
+ }
165
+ ];
166
+ }
87
167
  function isToolName(value) {
88
168
  return PRESENTATION_TOOL_NAMES.includes(value);
89
169
  }
@@ -102,15 +182,33 @@ export function getScopeDisplayConfig(loaded, scope) {
102
182
  }
103
183
  return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig, loaded.project.rawConfig);
104
184
  }
185
+ export function getInheritedBackendsForScope(loaded, scope) {
186
+ if (scope === 'global') {
187
+ return DEFAULT_BACKEND_CONFIG;
188
+ }
189
+ return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
190
+ }
191
+ export function getScopeDisplayBackends(loaded, scope) {
192
+ if (scope === 'global') {
193
+ return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
194
+ }
195
+ return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends, loaded.project.rawBackends);
196
+ }
105
197
  export function createSettingsDraftState(loaded, initialScope) {
106
198
  const drafts = {
107
199
  global: getScopeDisplayConfig(loaded, 'global'),
108
200
  project: getScopeDisplayConfig(loaded, 'project')
109
201
  };
202
+ const backendDrafts = {
203
+ global: getScopeDisplayBackends(loaded, 'global'),
204
+ project: getScopeDisplayBackends(loaded, 'project')
205
+ };
110
206
  return {
111
207
  scope: initialScope,
112
208
  drafts,
113
- config: clonePresentationConfig(drafts[initialScope])
209
+ backendDrafts,
210
+ config: clonePresentationConfig(drafts[initialScope]),
211
+ backends: cloneBackendConfig(backendDrafts[initialScope])
114
212
  };
115
213
  }
116
214
  export function applySettingsValue(state, id, newValue) {
@@ -118,16 +216,23 @@ export function applySettingsValue(state, id, newValue) {
118
216
  global: clonePresentationConfig(state.drafts.global),
119
217
  project: clonePresentationConfig(state.drafts.project)
120
218
  };
219
+ const nextBackendDrafts = {
220
+ global: cloneBackendConfig(state.backendDrafts.global),
221
+ project: cloneBackendConfig(state.backendDrafts.project)
222
+ };
121
223
  let nextScope = state.scope;
122
224
  if (id === 'scope' && (newValue === 'project' || newValue === 'global')) {
123
225
  nextScope = newValue;
124
226
  return {
125
227
  scope: nextScope,
126
228
  drafts: nextDrafts,
127
- config: clonePresentationConfig(nextDrafts[nextScope])
229
+ backendDrafts: nextBackendDrafts,
230
+ config: clonePresentationConfig(nextDrafts[nextScope]),
231
+ backends: cloneBackendConfig(nextBackendDrafts[nextScope])
128
232
  };
129
233
  }
130
234
  const currentDraft = clonePresentationConfig(nextDrafts[nextScope]);
235
+ const currentBackends = cloneBackendConfig(nextBackendDrafts[nextScope]);
131
236
  if (id === 'defaultMode' && (newValue === 'compact' || newValue === 'preview' || newValue === 'verbose')) {
132
237
  currentDraft.defaultMode = newValue;
133
238
  }
@@ -144,11 +249,58 @@ export function applySettingsValue(state, id, newValue) {
144
249
  }
145
250
  currentDraft.tools = nextTools;
146
251
  }
252
+ if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng')) {
253
+ currentBackends.search.provider = newValue;
254
+ if (newValue === 'duckduckgo') {
255
+ delete currentBackends.search.fallback;
256
+ }
257
+ }
258
+ if (id === 'backend:search:fallback') {
259
+ if (newValue === 'duckduckgo' && currentBackends.search.provider === 'searxng') {
260
+ currentBackends.search.fallback = 'duckduckgo';
261
+ }
262
+ else if (newValue === 'off' || currentBackends.search.provider !== 'searxng') {
263
+ delete currentBackends.search.fallback;
264
+ }
265
+ }
266
+ if (id === 'backend:search:baseUrl') {
267
+ if (newValue.trim()) {
268
+ currentBackends.search.baseUrl = newValue.trim();
269
+ }
270
+ else {
271
+ delete currentBackends.search.baseUrl;
272
+ }
273
+ }
274
+ if (id === 'backend:fetch:provider' && (newValue === 'http' || newValue === 'firecrawl')) {
275
+ currentBackends.fetch.provider = newValue;
276
+ if (newValue === 'http') {
277
+ delete currentBackends.fetch.fallback;
278
+ }
279
+ }
280
+ if (id === 'backend:fetch:fallback') {
281
+ if (newValue === 'http' && currentBackends.fetch.provider === 'firecrawl') {
282
+ currentBackends.fetch.fallback = 'http';
283
+ }
284
+ else if (newValue === 'off' || currentBackends.fetch.provider !== 'firecrawl') {
285
+ delete currentBackends.fetch.fallback;
286
+ }
287
+ }
288
+ if (id === 'backend:fetch:baseUrl') {
289
+ if (newValue.trim()) {
290
+ currentBackends.fetch.baseUrl = newValue.trim();
291
+ }
292
+ else {
293
+ delete currentBackends.fetch.baseUrl;
294
+ }
295
+ }
147
296
  nextDrafts[nextScope] = currentDraft;
297
+ nextBackendDrafts[nextScope] = currentBackends;
148
298
  return {
149
299
  scope: nextScope,
150
300
  drafts: nextDrafts,
151
- config: clonePresentationConfig(nextDrafts[nextScope])
301
+ backendDrafts: nextBackendDrafts,
302
+ config: clonePresentationConfig(nextDrafts[nextScope]),
303
+ backends: cloneBackendConfig(nextBackendDrafts[nextScope])
152
304
  };
153
305
  }
154
306
  export function collapsePresentationConfigToOverride(config, inheritedConfig) {
@@ -168,6 +320,44 @@ export function collapsePresentationConfigToOverride(config, inheritedConfig) {
168
320
  tools
169
321
  };
170
322
  }
323
+ export function collapseBackendConfigToOverride(config, inheritedConfig) {
324
+ const override = {};
325
+ if (!sameJson(config.search, inheritedConfig.search)) {
326
+ override.search = config.search.provider !== inheritedConfig.search.provider
327
+ ? { ...config.search }
328
+ : {
329
+ ...(config.search.baseUrl !== inheritedConfig.search.baseUrl ? { baseUrl: config.search.baseUrl } : {}),
330
+ ...(config.search.fallback !== inheritedConfig.search.fallback ? { fallback: config.search.fallback } : {}),
331
+ ...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {})
332
+ };
333
+ if (config.search.provider !== inheritedConfig.search.provider) {
334
+ override.search.provider = config.search.provider;
335
+ }
336
+ else if (Object.keys(override.search).length === 0) {
337
+ delete override.search;
338
+ }
339
+ }
340
+ if (!sameJson(config.fetch, inheritedConfig.fetch)) {
341
+ override.fetch = config.fetch.provider !== inheritedConfig.fetch.provider
342
+ ? { ...config.fetch, apiKey: undefined }
343
+ : {
344
+ ...(config.fetch.baseUrl !== inheritedConfig.fetch.baseUrl ? { baseUrl: config.fetch.baseUrl } : {}),
345
+ ...(config.fetch.fallback !== inheritedConfig.fetch.fallback ? { fallback: config.fetch.fallback } : {}),
346
+ ...(!sameJson(config.fetch.options, inheritedConfig.fetch.options) ? { options: config.fetch.options } : {})
347
+ };
348
+ delete override.fetch.apiKey;
349
+ if (config.fetch.provider !== inheritedConfig.fetch.provider) {
350
+ override.fetch.provider = config.fetch.provider;
351
+ }
352
+ else if (Object.keys(override.fetch).length === 0) {
353
+ delete override.fetch;
354
+ }
355
+ }
356
+ if (!sameJson(config.headless, inheritedConfig.headless)) {
357
+ override.headless = { ...config.headless };
358
+ }
359
+ return override;
360
+ }
171
361
  export function handleSettingsShortcut(data) {
172
362
  if (data === '\u001b') {
173
363
  return { action: 'cancel' };
@@ -184,9 +374,10 @@ async function openActionMenu(ctx) {
184
374
  return ctx.ui.custom((tui, theme, _kb, done) => {
185
375
  const container = new Container();
186
376
  const items = [
187
- { value: 'settings', label: 'Settings', description: 'Edit presentation modes' },
377
+ { value: 'settings', label: 'Settings', description: 'Edit presentation modes and backends' },
188
378
  { value: 'show', label: 'Show config', description: 'Print effective config paths and modes' },
189
379
  { value: 'doctor', label: 'Doctor', description: 'Check runtime dependencies and browser detection' },
380
+ { value: 'changelog', label: 'Changelog', description: 'Show latest package changelog' },
190
381
  { value: 'reset-project', label: 'Reset project config', description: 'Delete project-level overrides' },
191
382
  { value: 'reset-global', label: 'Reset global config', description: 'Delete global overrides' }
192
383
  ];
@@ -214,22 +405,120 @@ async function openActionMenu(ctx) {
214
405
  };
215
406
  });
216
407
  }
217
- async function openSettingsUi(ctx, loaded, initialScope) {
408
+ async function openSettingsSectionMenu(ctx) {
409
+ return ctx.ui.custom((tui, theme, _kb, done) => {
410
+ const container = new Container();
411
+ const items = [
412
+ { value: 'presentation', label: 'Presentation', description: 'Compact, preview, and verbose output modes' },
413
+ { value: 'backends', label: 'Backends', description: 'Search/fetch providers, URLs, and fallbacks' }
414
+ ];
415
+ container.addChild(new DynamicBorder((text) => theme.fg('accent', text)));
416
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent settings')), 1, 0));
417
+ const list = new SelectList(items, items.length, {
418
+ selectedPrefix: (text) => theme.fg('accent', text),
419
+ selectedText: (text) => theme.fg('accent', text),
420
+ description: (text) => theme.fg('muted', text),
421
+ scrollInfo: (text) => theme.fg('dim', text),
422
+ noMatch: (text) => theme.fg('warning', text)
423
+ });
424
+ list.onSelect = (item) => done(item.value);
425
+ list.onCancel = () => done(undefined);
426
+ container.addChild(list);
427
+ container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0));
428
+ container.addChild(new DynamicBorder((text) => theme.fg('accent', text)));
429
+ return {
430
+ render: (width) => container.render(width),
431
+ invalidate: () => container.invalidate(),
432
+ handleInput: (data) => {
433
+ list.handleInput?.(data);
434
+ tui.requestRender?.();
435
+ }
436
+ };
437
+ });
438
+ }
439
+ async function openPresentationSettingsUi(ctx, loaded, initialScope) {
218
440
  return ctx.ui.custom((_tui, theme, _kb, done) => {
219
441
  let state = createSettingsDraftState(loaded, initialScope);
220
442
  let settingsList;
221
443
  const container = new Container();
222
- container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent settings')), 1, 1));
444
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · presentation')), 1, 1));
223
445
  container.addChild(new Text(theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel'), 1, 2));
224
446
  const rebuildSettingsList = () => {
225
447
  if (settingsList) {
226
448
  container.removeChild(settingsList);
227
449
  }
228
- settingsList = new SettingsList(buildSettingsItems(state.scope, state.config), Math.min(PRESENTATION_TOOL_NAMES.length + 8, 18), getSettingsListTheme(), (id, newValue) => {
450
+ settingsList = new SettingsList(buildPresentationSettingsItems(state.scope, state.config), Math.min(PRESENTATION_TOOL_NAMES.length + 8, 18), getSettingsListTheme(), (id, newValue) => {
451
+ state = applySettingsValue(state, id, newValue);
452
+ rebuildSettingsList();
453
+ container.invalidate();
454
+ }, () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }), { enableSearch: true });
455
+ container.addChild(settingsList);
456
+ };
457
+ rebuildSettingsList();
458
+ return {
459
+ render: (width) => container.render(width),
460
+ invalidate: () => container.invalidate(),
461
+ handleInput: (data) => {
462
+ const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
463
+ if (shortcut?.action === 'cancel') {
464
+ done({ action: 'cancel' });
465
+ return;
466
+ }
467
+ if (shortcut?.action === 'reset') {
468
+ done({ action: 'reset', scope: state.scope });
469
+ return;
470
+ }
471
+ if (shortcut?.action === 'save') {
472
+ done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
473
+ return;
474
+ }
475
+ settingsList.handleInput?.(data);
476
+ }
477
+ };
478
+ });
479
+ }
480
+ async function openBackendSettingsUi(ctx, loaded, initialScope) {
481
+ return ctx.ui.custom((tui, theme, _kb, done) => {
482
+ let state = createSettingsDraftState(loaded, initialScope);
483
+ let settingsList;
484
+ const container = new Container();
485
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · backends')), 1, 1));
486
+ container.addChild(new Text(theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel · API keys stay in env vars'), 1, 2));
487
+ const editUrl = async (id) => {
488
+ const isSearchUrl = id === 'backend:search:baseUrl';
489
+ const label = isSearchUrl ? 'SearXNG base URL' : 'Firecrawl base URL';
490
+ const currentValue = isSearchUrl ? state.backends.search.baseUrl : state.backends.fetch.baseUrl;
491
+ const entered = await ctx.ui.input(label, currentValue ?? (isSearchUrl ? 'http://localhost:8080' : 'http://localhost:3002'));
492
+ if (entered === undefined)
493
+ return;
494
+ if (!entered.trim()) {
495
+ state = applySettingsValue(state, id, '');
496
+ rebuildSettingsList();
497
+ tui.requestRender?.();
498
+ return;
499
+ }
500
+ const validated = validateBackendUrl(entered);
501
+ if (!validated.ok) {
502
+ ctx.ui.notify(validated.message, 'warning');
503
+ return;
504
+ }
505
+ state = applySettingsValue(state, id, validated.value);
506
+ rebuildSettingsList();
507
+ tui.requestRender?.();
508
+ };
509
+ const rebuildSettingsList = () => {
510
+ if (settingsList) {
511
+ container.removeChild(settingsList);
512
+ }
513
+ settingsList = new SettingsList(buildBackendSettingsItems(state.scope, state.backends), 12, getSettingsListTheme(), (id, newValue) => {
514
+ if (id === 'backend:search:baseUrl' || id === 'backend:fetch:baseUrl') {
515
+ void editUrl(id);
516
+ return;
517
+ }
229
518
  state = applySettingsValue(state, id, newValue);
230
519
  rebuildSettingsList();
231
520
  container.invalidate();
232
- }, () => done({ action: 'save', scope: state.scope, config: state.config }), { enableSearch: true });
521
+ }, () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }), { enableSearch: true });
233
522
  container.addChild(settingsList);
234
523
  };
235
524
  rebuildSettingsList();
@@ -247,7 +536,7 @@ async function openSettingsUi(ctx, loaded, initialScope) {
247
536
  return;
248
537
  }
249
538
  if (shortcut?.action === 'save') {
250
- done({ action: 'save', scope: state.scope, config: state.config });
539
+ done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
251
540
  return;
252
541
  }
253
542
  settingsList.handleInput?.(data);
@@ -258,6 +547,7 @@ async function openSettingsUi(ctx, loaded, initialScope) {
258
547
  export function registerWebAgentConfigCommands(pi, deps = {}) {
259
548
  const load = deps.load ?? (() => loadPresentationConfigLayers());
260
549
  const save = deps.save ?? ((scope, config) => savePresentationConfigScope({}, scope, config));
550
+ const saveBackends = deps.saveBackends ?? ((scope, config) => saveBackendConfigScope({}, scope, config));
261
551
  const reset = deps.reset ?? ((scope) => resetPresentationConfigScope({}, scope));
262
552
  const resolveBrowser = deps.resolveBrowser ?? (() => resolveBrowserExecutable({}));
263
553
  const runtime = deps.runtime ?? {
@@ -362,7 +652,12 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
362
652
  if (!action || action === 'settings') {
363
653
  const loaded = await load();
364
654
  const initialScope = 'project';
365
- const result = await openSettingsUi(ctx, loaded, initialScope);
655
+ const section = await openSettingsSectionMenu(ctx);
656
+ if (!section)
657
+ return;
658
+ const result = section === 'presentation'
659
+ ? await openPresentationSettingsUi(ctx, loaded, initialScope)
660
+ : await openBackendSettingsUi(ctx, loaded, initialScope);
366
661
  if (!result || result.action === 'cancel') {
367
662
  return;
368
663
  }
@@ -371,8 +666,13 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
371
666
  ctx.ui.notify(`Reset ${result.scope} config`, 'info');
372
667
  return;
373
668
  }
374
- await save(result.scope, collapsePresentationConfigToOverride(result.config, getInheritedConfigForScope(loaded, result.scope)));
375
- ctx.ui.notify(`Saved ${result.scope} config`, 'info');
669
+ if (section === 'presentation') {
670
+ await save(result.scope, collapsePresentationConfigToOverride(result.config, getInheritedConfigForScope(loaded, result.scope)));
671
+ ctx.ui.notify(`Saved ${result.scope} presentation config`, 'info');
672
+ return;
673
+ }
674
+ await saveBackends(result.scope, collapseBackendConfigToOverride(result.backends, getInheritedBackendsForScope(loaded, result.scope)));
675
+ ctx.ui.notify(`Saved ${result.scope} backend config`, 'info');
376
676
  return;
377
677
  }
378
678
  ctx.ui.notify('Use /web-agent, /web-agent show, /web-agent doctor, /web-agent changelog, /web-agent reset project, or /web-agent settings', 'info');
@@ -23,4 +23,5 @@ export declare function getPresentationConfigPaths(options?: PresentationConfigS
23
23
  };
24
24
  export declare function loadPresentationConfigLayers(options?: PresentationConfigStoreOptions): Promise<LoadedPresentationConfig>;
25
25
  export declare function savePresentationConfigScope(options: PresentationConfigStoreOptions, scope: PresentationScope, config: PresentationConfigOverride): Promise<void>;
26
+ export declare function saveBackendConfigScope(options: PresentationConfigStoreOptions, scope: PresentationScope, config: BackendConfigOverride): Promise<void>;
26
27
  export declare function resetPresentationConfigScope(options: PresentationConfigStoreOptions, scope: PresentationScope): Promise<void>;
@@ -49,6 +49,35 @@ function serializePresentationConfigOverride(config) {
49
49
  }
50
50
  return { presentation };
51
51
  }
52
+ function serializeBackendConfigOverride(config) {
53
+ const backends = {};
54
+ if (config.search && Object.keys(config.search).length > 0) {
55
+ backends.search = { ...config.search };
56
+ }
57
+ if (config.fetch && Object.keys(config.fetch).length > 0) {
58
+ const { apiKey: _apiKey, ...fetch } = config.fetch;
59
+ backends.fetch = { ...fetch };
60
+ }
61
+ if (config.headless && Object.keys(config.headless).length > 0) {
62
+ backends.headless = { ...config.headless };
63
+ }
64
+ return { backends };
65
+ }
66
+ async function readConfigFileForWrite(filePath) {
67
+ try {
68
+ return JSON.parse(await readFile(filePath, 'utf8'));
69
+ }
70
+ catch (error) {
71
+ if (error?.code === 'ENOENT') {
72
+ return {};
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ async function writeConfigFile(filePath, config) {
78
+ await mkdir(path.dirname(filePath), { recursive: true });
79
+ await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8');
80
+ }
52
81
  export async function loadPresentationConfigLayers(options = {}) {
53
82
  const { globalPath, projectPath } = getPresentationConfigPaths(options);
54
83
  const global = await readPresentationConfigFile(globalPath);
@@ -63,8 +92,20 @@ export async function loadPresentationConfigLayers(options = {}) {
63
92
  export async function savePresentationConfigScope(options, scope, config) {
64
93
  const { globalPath, projectPath } = getPresentationConfigPaths(options);
65
94
  const filePath = scope === 'global' ? globalPath : projectPath;
66
- await mkdir(path.dirname(filePath), { recursive: true });
67
- await writeFile(filePath, JSON.stringify(serializePresentationConfigOverride(config), null, 2) + '\n', 'utf8');
95
+ const existing = await readConfigFileForWrite(filePath);
96
+ await writeConfigFile(filePath, {
97
+ ...existing,
98
+ ...serializePresentationConfigOverride(config)
99
+ });
100
+ }
101
+ export async function saveBackendConfigScope(options, scope, config) {
102
+ const { globalPath, projectPath } = getPresentationConfigPaths(options);
103
+ const filePath = scope === 'global' ? globalPath : projectPath;
104
+ const existing = await readConfigFileForWrite(filePath);
105
+ await writeConfigFile(filePath, {
106
+ ...existing,
107
+ ...serializeBackendConfigOverride(config)
108
+ });
68
109
  }
69
110
  export async function resetPresentationConfigScope(options, scope) {
70
111
  const { globalPath, projectPath } = getPresentationConfigPaths(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",