@selesai/code 0.9.10 → 0.9.11

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 (302) hide show
  1. package/CHANGELOG.md +260 -255
  2. package/dist/cli/args.js +13 -2
  3. package/dist/cli/args.test.js +8 -0
  4. package/dist/cli/credential-print.js +4 -4
  5. package/dist/cli.js +0 -0
  6. package/dist/core/agent-session-auto-handoff.test.js +24 -1
  7. package/dist/core/agent-session.d.ts +11 -4
  8. package/dist/core/agent-session.js +96 -35
  9. package/dist/core/compaction/branch-summarization.js +30 -30
  10. package/dist/core/compaction/compaction.js +81 -81
  11. package/dist/core/compaction/utils.js +2 -2
  12. package/dist/core/defaults.d.ts +1 -0
  13. package/dist/core/defaults.js +9 -0
  14. package/dist/core/export-html/template.css +1066 -1066
  15. package/dist/core/export-html/template.html +55 -55
  16. package/dist/core/export-html/template.js +1864 -1864
  17. package/dist/core/export-html/vendor/highlight.min.js +1212 -1212
  18. package/dist/core/export-html/vendor/marked.min.js +78 -78
  19. package/dist/core/extensions/loader.js +108 -35
  20. package/dist/core/extensions/loader.test.d.ts +1 -0
  21. package/dist/core/extensions/loader.test.js +21 -0
  22. package/dist/core/extensions/types.d.ts +12 -2
  23. package/dist/core/keybindings.d.ts +2 -2
  24. package/dist/core/messages.js +7 -7
  25. package/dist/core/model-resolver.d.ts +1 -0
  26. package/dist/core/model-resolver.js +10 -4
  27. package/dist/core/package-manager.js +4 -4
  28. package/dist/core/sdk.d.ts +5 -5
  29. package/dist/core/sdk.js +16 -4
  30. package/dist/core/settings-manager.d.ts +6 -0
  31. package/dist/core/settings-manager.js +22 -0
  32. package/dist/core/tools/bash.d.ts +18 -1
  33. package/dist/core/tools/bash.js +41 -23
  34. package/dist/core/tools/index.d.ts +4 -1
  35. package/dist/core/tools/index.js +9 -1
  36. package/dist/core/tools/powershell.d.ts +15 -0
  37. package/dist/core/tools/powershell.js +38 -0
  38. package/dist/core/tools/powershell.test.d.ts +1 -0
  39. package/dist/core/tools/powershell.test.js +20 -0
  40. package/dist/extensions/context-compaction-reminder.test.ts +82 -82
  41. package/dist/extensions/context-compaction-reminder.ts +28 -28
  42. package/dist/extensions/pi-intercom/LICENSE +21 -21
  43. package/dist/extensions/pi-intercom/broker/client.test.ts +83 -83
  44. package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -387
  45. package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -114
  46. package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -153
  47. package/dist/extensions/pi-intercom/broker/paths.ts +134 -134
  48. package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -34
  49. package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -21
  50. package/dist/extensions/pi-intercom/cwd.test.ts +40 -40
  51. package/dist/extensions/pi-intercom/cwd.ts +31 -31
  52. package/dist/extensions/pi-intercom/extension-api.ts +44 -44
  53. package/dist/extensions/pi-intercom/format-context.test.ts +31 -31
  54. package/dist/extensions/pi-intercom/format-context.ts +32 -32
  55. package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -66
  56. package/dist/extensions/pi-intercom/ui/compose.ts +143 -143
  57. package/dist/extensions/pi-intercom/ui/session-list.ts +166 -166
  58. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +286 -286
  59. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +108 -108
  60. package/dist/extensions/pi-powerline-footer/separators.ts +57 -57
  61. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +47 -47
  62. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -39
  63. package/dist/extensions/pi-powerline-footer/theme.example.json +24 -24
  64. package/dist/extensions/pi-powerline-footer/theme.json +12 -12
  65. package/dist/extensions/pi-powerline-footer/tps.ts +345 -345
  66. package/dist/extensions/pi-rewind-hook/README.md +245 -245
  67. package/dist/extensions/pi-rewind-hook/index.ts +1445 -1445
  68. package/dist/extensions/pi-rewind-hook/package.json +29 -29
  69. package/dist/extensions/pi-subagents/install.mjs +0 -0
  70. package/dist/extensions/pi-web-agent/package.json +31 -31
  71. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -205
  72. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -136
  73. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -152
  74. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -25
  75. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -28
  76. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +136 -136
  77. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -946
  78. package/dist/extensions/pi-web-agent/src/extension.ts +126 -126
  79. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -118
  80. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -199
  81. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -100
  82. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -117
  83. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -67
  84. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -60
  85. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -50
  86. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -52
  87. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -105
  88. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -45
  89. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -28
  90. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -47
  91. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -376
  92. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -64
  93. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -181
  94. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -101
  95. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -81
  96. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +210 -210
  97. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -75
  98. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -61
  99. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -54
  100. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -41
  101. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -20
  102. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -63
  103. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -114
  104. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -72
  105. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -96
  106. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -71
  107. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -31
  108. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -31
  109. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -157
  110. package/dist/extensions/pi-web-agent/src/types.ts +88 -88
  111. package/dist/extensions/ponytail/package.json +8 -8
  112. package/dist/extensions/question/batch.ts +103 -103
  113. package/dist/extensions/question/constants.ts +30 -30
  114. package/dist/extensions/question/helpers.ts +58 -58
  115. package/dist/extensions/question/navigation.ts +14 -14
  116. package/dist/extensions/question/package.json +19 -19
  117. package/dist/extensions/question/schemas.ts +43 -43
  118. package/dist/extensions/question/selection-mode.ts +46 -46
  119. package/dist/extensions/question/shortcuts.ts +44 -44
  120. package/dist/extensions/question/types.ts +132 -132
  121. package/dist/extensions/test-resolve-hook-impl.mjs +6 -6
  122. package/dist/extensions/test-resolve-hook.mjs +6 -6
  123. package/dist/extensions/web-agent-onboarding.ts +222 -222
  124. package/dist/extensions/workflow/package.json +17 -17
  125. package/dist/package-manager-cli.js +71 -71
  126. package/dist/rpc-entry.js +0 -0
  127. package/dist/skills/agent-browser/SKILL.md +52 -52
  128. package/dist/skills/batch-grill-me/SKILL.md +19 -19
  129. package/dist/skills/grill-me/SKILL.md +10 -10
  130. package/dist/skills/handoff/SKILL.md +16 -16
  131. package/dist/skills/handoff-text/SKILL.md +14 -14
  132. package/dist/skills/implanger/SKILL.md +69 -69
  133. package/dist/skills/improve-codebase/REFERENCE.md +78 -78
  134. package/dist/skills/improve-codebase/SKILL.md +178 -178
  135. package/dist/skills/planger/SKILL.md +166 -166
  136. package/dist/skills/ponytail/SKILL.md +116 -116
  137. package/dist/skills/ponytail-audit/SKILL.md +42 -42
  138. package/dist/skills/ponytail-debt/SKILL.md +45 -45
  139. package/dist/skills/ponytail-gain/SKILL.md +51 -51
  140. package/dist/skills/ponytail-help/SKILL.md +70 -70
  141. package/dist/skills/ponytail-review/SKILL.md +58 -58
  142. package/dist/skills/selesai-handoff/SKILL.md +20 -20
  143. package/dist/skills/workflow-creation/SKILL.md +73 -73
  144. package/dist/themes/powerline-footer/theme.json +33 -33
  145. package/dist/utils/shell.d.ts +3 -0
  146. package/dist/utils/shell.js +17 -5
  147. package/docs/compaction.md +396 -396
  148. package/docs/containerization.md +111 -111
  149. package/docs/development.md +71 -71
  150. package/docs/docs.json +164 -164
  151. package/docs/environment-variables.md +86 -86
  152. package/docs/index.md +83 -83
  153. package/docs/json.md +82 -82
  154. package/docs/models.md +502 -502
  155. package/docs/packages.md +227 -227
  156. package/docs/plans/subagent-delegation/phase-0-correctness.md +264 -264
  157. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +485 -485
  158. package/docs/plans/subagent-delegation/phase-2-context-controls.md +281 -281
  159. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +361 -361
  160. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +380 -380
  161. package/docs/prompt-templates.md +95 -95
  162. package/docs/providers.md +293 -293
  163. package/docs/sdk.md +1144 -1143
  164. package/docs/security.md +59 -59
  165. package/docs/session-format.md +414 -414
  166. package/docs/sessions.md +145 -145
  167. package/docs/shared-host-extensions.md +109 -109
  168. package/docs/shell-aliases.md +13 -13
  169. package/docs/skills.md +231 -231
  170. package/docs/terminal-setup.md +142 -142
  171. package/docs/termux.md +127 -127
  172. package/docs/themes.md +295 -295
  173. package/docs/tmux.md +63 -63
  174. package/docs/tui.md +927 -927
  175. package/docs/windows.md +17 -17
  176. package/examples/README.md +25 -25
  177. package/examples/extensions/README.md +211 -211
  178. package/examples/extensions/auto-commit-on-exit.ts +49 -49
  179. package/examples/extensions/bash-spawn-hook.ts +30 -30
  180. package/examples/extensions/bookmark.ts +50 -50
  181. package/examples/extensions/border-status-editor.ts +150 -150
  182. package/examples/extensions/built-in-tool-renderer.ts +249 -249
  183. package/examples/extensions/claude-rules.ts +86 -86
  184. package/examples/extensions/commands.ts +72 -72
  185. package/examples/extensions/confirm-destructive.ts +59 -59
  186. package/examples/extensions/custom-compaction.ts +130 -130
  187. package/examples/extensions/custom-footer.ts +64 -64
  188. package/examples/extensions/custom-header.ts +73 -73
  189. package/examples/extensions/custom-provider-anthropic/index.ts +610 -610
  190. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -24
  191. package/examples/extensions/custom-provider-anthropic/package.json +19 -19
  192. package/examples/extensions/custom-provider-gitlab-duo/index.ts +404 -404
  193. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -16
  194. package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -82
  195. package/examples/extensions/dirty-repo-guard.ts +56 -56
  196. package/examples/extensions/doom-overlay/README.md +46 -46
  197. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -21
  198. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  199. package/examples/extensions/doom-overlay/doom/build.sh +152 -152
  200. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -72
  201. package/examples/extensions/doom-overlay/doom-component.ts +132 -132
  202. package/examples/extensions/doom-overlay/doom-engine.ts +173 -173
  203. package/examples/extensions/doom-overlay/doom-keys.ts +104 -104
  204. package/examples/extensions/doom-overlay/index.ts +74 -74
  205. package/examples/extensions/doom-overlay/wad-finder.ts +51 -51
  206. package/examples/extensions/dynamic-resources/SKILL.md +8 -8
  207. package/examples/extensions/dynamic-resources/dynamic.json +79 -79
  208. package/examples/extensions/dynamic-resources/dynamic.md +5 -5
  209. package/examples/extensions/dynamic-resources/index.ts +15 -15
  210. package/examples/extensions/dynamic-tools.ts +74 -74
  211. package/examples/extensions/event-bus.ts +43 -43
  212. package/examples/extensions/file-trigger.ts +41 -41
  213. package/examples/extensions/git-checkpoint.ts +53 -53
  214. package/examples/extensions/git-merge-and-resolve.ts +115 -115
  215. package/examples/extensions/github-issue-autocomplete.ts +185 -185
  216. package/examples/extensions/gondolin/index.ts +531 -531
  217. package/examples/extensions/gondolin/package-lock.json +185 -185
  218. package/examples/extensions/gondolin/package.json +19 -19
  219. package/examples/extensions/handoff.ts +199 -199
  220. package/examples/extensions/hello.ts +26 -26
  221. package/examples/extensions/hidden-thinking-label.ts +53 -53
  222. package/examples/extensions/inline-bash.ts +94 -94
  223. package/examples/extensions/input-transform-streaming.ts +39 -39
  224. package/examples/extensions/input-transform.ts +43 -43
  225. package/examples/extensions/interactive-shell.ts +196 -196
  226. package/examples/extensions/mac-system-theme.ts +47 -47
  227. package/examples/extensions/message-renderer.ts +59 -59
  228. package/examples/extensions/minimal-mode.ts +426 -426
  229. package/examples/extensions/modal-editor.ts +85 -85
  230. package/examples/extensions/model-status.ts +31 -31
  231. package/examples/extensions/notify.ts +55 -55
  232. package/examples/extensions/overlay-qa-tests.ts +1450 -1450
  233. package/examples/extensions/overlay-test.ts +153 -153
  234. package/examples/extensions/permission-gate.ts +34 -34
  235. package/examples/extensions/pirate.ts +47 -47
  236. package/examples/extensions/plan-mode/README.md +66 -66
  237. package/examples/extensions/plan-mode/index.ts +390 -390
  238. package/examples/extensions/plan-mode/utils.ts +168 -168
  239. package/examples/extensions/preset.ts +436 -436
  240. package/examples/extensions/project-trust.ts +64 -64
  241. package/examples/extensions/prompt-customizer.ts +97 -97
  242. package/examples/extensions/protected-paths.ts +30 -30
  243. package/examples/extensions/provider-payload.ts +18 -18
  244. package/examples/extensions/qna.ts +122 -122
  245. package/examples/extensions/question.ts +285 -285
  246. package/examples/extensions/questionnaire.ts +448 -448
  247. package/examples/extensions/rainbow-editor.ts +88 -88
  248. package/examples/extensions/reload-runtime.ts +37 -37
  249. package/examples/extensions/rpc-demo.ts +118 -118
  250. package/examples/extensions/sandbox/index.ts +321 -321
  251. package/examples/extensions/sandbox/package-lock.json +92 -92
  252. package/examples/extensions/sandbox/package.json +19 -19
  253. package/examples/extensions/send-user-message.ts +97 -97
  254. package/examples/extensions/session-name.ts +27 -27
  255. package/examples/extensions/shutdown-command.ts +63 -63
  256. package/examples/extensions/snake.ts +343 -343
  257. package/examples/extensions/space-invaders.ts +560 -560
  258. package/examples/extensions/ssh.ts +220 -220
  259. package/examples/extensions/status-line.ts +32 -32
  260. package/examples/extensions/structured-output.ts +65 -65
  261. package/examples/extensions/subagent/README.md +175 -175
  262. package/examples/extensions/subagent/agents/planner.md +37 -37
  263. package/examples/extensions/subagent/agents/reviewer.md +35 -35
  264. package/examples/extensions/subagent/agents/scout.md +50 -50
  265. package/examples/extensions/subagent/agents/worker.md +24 -24
  266. package/examples/extensions/subagent/agents.ts +126 -126
  267. package/examples/extensions/subagent/index.ts +1015 -1015
  268. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -10
  269. package/examples/extensions/subagent/prompts/implement.md +10 -10
  270. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -9
  271. package/examples/extensions/summarize.ts +209 -209
  272. package/examples/extensions/system-prompt-header.ts +17 -17
  273. package/examples/extensions/tic-tac-toe.ts +1008 -1008
  274. package/examples/extensions/timed-confirm.ts +70 -70
  275. package/examples/extensions/titlebar-spinner.ts +58 -58
  276. package/examples/extensions/todo.ts +297 -297
  277. package/examples/extensions/tool-override.ts +144 -144
  278. package/examples/extensions/tools.ts +146 -146
  279. package/examples/extensions/trigger-compact.ts +50 -50
  280. package/examples/extensions/truncated-tool.ts +195 -195
  281. package/examples/extensions/widget-placement.ts +9 -9
  282. package/examples/extensions/with-deps/index.ts +32 -32
  283. package/examples/extensions/with-deps/package-lock.json +31 -31
  284. package/examples/extensions/with-deps/package.json +22 -22
  285. package/examples/extensions/working-indicator.ts +123 -123
  286. package/examples/extensions/working-message-test.ts +25 -25
  287. package/examples/rpc-extension-ui.ts +632 -632
  288. package/examples/sdk/01-minimal.ts +26 -26
  289. package/examples/sdk/02-custom-model.ts +53 -53
  290. package/examples/sdk/03-custom-prompt.ts +75 -75
  291. package/examples/sdk/04-skills.ts +55 -55
  292. package/examples/sdk/05-tools.ts +48 -48
  293. package/examples/sdk/06-extensions.ts +99 -99
  294. package/examples/sdk/07-context-files.ts +47 -47
  295. package/examples/sdk/08-prompt-templates.ts +51 -51
  296. package/examples/sdk/09-api-keys-and-oauth.ts +52 -52
  297. package/examples/sdk/10-settings.ts +53 -53
  298. package/examples/sdk/11-sessions.ts +52 -52
  299. package/examples/sdk/12-full-control.ts +79 -79
  300. package/examples/sdk/13-session-runtime.ts +67 -67
  301. package/examples/sdk/README.md +144 -144
  302. package/package.json +4 -4
@@ -1,946 +1,946 @@
1
- import {
2
- DEFAULT_BACKEND_CONFIG,
3
- mergeBackendConfigLayers,
4
- validateBackendConfig,
5
- type BackendConfig,
6
- type BackendConfigOverride
7
- } from '../backends/config.js';
8
- import { checkBackendHealth } from '../backends/doctor.js';
9
- import {
10
- DynamicBorder,
11
- getSettingsListTheme,
12
- type ExtensionAPI
13
- } from '@selesai/code';
14
- import { Container, SelectList, SettingsList, Text, type SelectItem, type SettingItem } from '@earendil-works/pi-tui';
15
- import {
16
- DEFAULT_PRESENTATION_CONFIG,
17
- mergePresentationConfigLayers,
18
- resolvePresentationMode
19
- } from '../presentation/config.js';
20
- import {
21
- loadPresentationConfigLayers,
22
- resetPresentationConfigScope,
23
- saveBackendConfigScope,
24
- savePresentationConfigScope,
25
- type LoadedPresentationConfig
26
- } from '../presentation/config-store.js';
27
- import { resolveBrowserExecutable, type BrowserResolutionResult } from '../fetch/browser-resolution.js';
28
- import { getLatestChangelogEntry } from '../changelog-notice.js';
29
- import type {
30
- PresentationConfig,
31
- PresentationConfigOverride,
32
- PresentationScope,
33
- PresentationToolName
34
- } from '../presentation/types.js';
35
-
36
- type CommandDeps = {
37
- load?: () => ReturnType<typeof loadPresentationConfigLayers>;
38
- save?: (scope: PresentationScope, config: PresentationConfigOverride) => Promise<void>;
39
- saveBackends?: (scope: PresentationScope, config: BackendConfigOverride) => Promise<void>;
40
- reset?: (scope: PresentationScope) => Promise<void>;
41
- resolveBrowser?: () => Promise<BrowserResolutionResult>;
42
- runtime?: { nodeVersion: string; platform: string; arch: string };
43
- checkTypebox?: () => Promise<boolean>;
44
- checkBackends?: (config: BackendConfig) => Promise<string[]>;
45
- getChangelog?: () => Promise<string | undefined>;
46
- };
47
-
48
- type SettingsUiResult =
49
- | { action: 'cancel' }
50
- | { action: 'reset'; scope: PresentationScope }
51
- | { action: 'save'; scope: PresentationScope; config: PresentationConfig; backends: BackendConfig };
52
-
53
- type WebAgentAction = 'settings' | 'show' | 'doctor' | 'changelog' | 'reset-project' | 'reset-global';
54
- type SettingsSection = 'presentation' | 'backends';
55
-
56
- export type SettingsDraftState = {
57
- scope: PresentationScope;
58
- drafts: Record<PresentationScope, PresentationConfig>;
59
- backendDrafts: Record<PresentationScope, BackendConfig>;
60
- config: PresentationConfig;
61
- backends: BackendConfig;
62
- };
63
-
64
- const PRESENTATION_TOOL_NAMES: PresentationToolName[] = ['web_explore'];
65
-
66
- function parseScopeToken(token: string | undefined): PresentationScope | undefined {
67
- return token === 'global' || token === 'project' ? token : undefined;
68
- }
69
-
70
- function clonePresentationConfig(config: PresentationConfig): PresentationConfig {
71
- return {
72
- defaultMode: config.defaultMode,
73
- tools: { ...config.tools }
74
- };
75
- }
76
-
77
- function cloneBackendConfig(config: BackendConfig): BackendConfig {
78
- return {
79
- search: {
80
- ...config.search,
81
- options: config.search.options ? { ...config.search.options } : undefined
82
- },
83
- fetch: {
84
- ...config.fetch,
85
- options: config.fetch.options ? { ...config.fetch.options } : undefined
86
- },
87
- headless: { ...config.headless }
88
- };
89
- }
90
-
91
- function sameJson(left: unknown, right: unknown): boolean {
92
- return JSON.stringify(left) === JSON.stringify(right);
93
- }
94
-
95
- export function validateBackendUrl(value: string): { ok: true; value: string } | { ok: false; message: string } {
96
- try {
97
- const url = new URL(value.trim());
98
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
99
- return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
100
- }
101
-
102
- return { ok: true, value: url.toString().replace(/\/$/, '') };
103
- } catch {
104
- return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
105
- }
106
- }
107
-
108
- async function defaultCheckTypebox(): Promise<boolean> {
109
- try {
110
- await import('typebox');
111
- return true;
112
- } catch {
113
- return false;
114
- }
115
- }
116
-
117
- function formatSearchOptions(config: BackendConfig['search']) {
118
- return [
119
- config.fallback ? `fallback ${config.fallback}` : undefined,
120
- config.options?.categories?.length ? `categories ${config.options.categories.join(',')}` : undefined,
121
- config.options?.language ? `language ${config.options.language}` : undefined,
122
- config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined
123
- ].filter(Boolean).join(' ');
124
- }
125
-
126
- function formatFetchOptions(config: BackendConfig['fetch']) {
127
- return [
128
- config.fallback ? `fallback ${config.fallback}` : undefined,
129
- config.options?.formats?.length ? `formats ${config.options.formats.join(',')}` : undefined,
130
- config.options?.onlyMainContent !== undefined ? `onlyMainContent ${config.options.onlyMainContent}` : undefined
131
- ].filter(Boolean).join(' ');
132
- }
133
-
134
- function formatBackendSummary(config: BackendConfig = DEFAULT_BACKEND_CONFIG) {
135
- const searchSuffix = formatSearchOptions(config.search);
136
- const fetchSuffix = formatFetchOptions(config.fetch);
137
- const searchBase = config.search.baseUrl
138
- ? `search: ${config.search.provider} (${config.search.baseUrl})`
139
- : `search: ${config.search.provider}`;
140
- const fetchBase = config.fetch.baseUrl
141
- ? `fetch: ${config.fetch.provider} (${config.fetch.baseUrl})`
142
- : `fetch: ${config.fetch.provider}`;
143
-
144
- return [
145
- searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
146
- fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
147
- `headless: ${config.headless.provider}`
148
- ].join('\n');
149
- }
150
-
151
- function formatConfigSummary(config: PresentationConfig) {
152
- const lines = [`defaultMode: ${config.defaultMode}`];
153
-
154
- for (const toolName of PRESENTATION_TOOL_NAMES) {
155
- lines.push(`${toolName}: ${config.tools[toolName]?.mode ?? 'inherit'}`);
156
- }
157
-
158
- return lines.join('\n');
159
- }
160
-
161
- function buildPresentationSettingsItems(scope: PresentationScope, config: PresentationConfig): SettingItem[] {
162
- return [
163
- {
164
- id: 'scope',
165
- label: 'Write scope',
166
- currentValue: scope,
167
- values: ['project', 'global']
168
- },
169
- {
170
- id: 'defaultMode',
171
- label: 'Default mode',
172
- currentValue: config.defaultMode,
173
- values: ['compact', 'preview', 'verbose']
174
- },
175
- ...PRESENTATION_TOOL_NAMES.map((toolName) => ({
176
- id: `tool:${toolName}`,
177
- label: toolName,
178
- currentValue: config.tools[toolName]?.mode ?? 'inherit',
179
- values: ['inherit', 'compact', 'preview', 'verbose']
180
- }))
181
- ];
182
- }
183
-
184
- function buildBackendSettingsItems(scope: PresentationScope, backends: BackendConfig): SettingItem[] {
185
- return [
186
- {
187
- id: 'scope',
188
- label: 'Write scope',
189
- currentValue: scope,
190
- values: ['project', 'global']
191
- },
192
- {
193
- id: 'backend:search:provider',
194
- label: 'Search backend',
195
- currentValue: backends.search.provider,
196
- values: ['duckduckgo', 'searxng', 'brave']
197
- },
198
- {
199
- id: 'backend:search:baseUrl',
200
- label: 'SearXNG URL',
201
- currentValue: backends.search.baseUrl ?? 'not set',
202
- values: ['edit']
203
- },
204
- {
205
- id: 'backend:search:fallback',
206
- label: 'Search fallback',
207
- currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? backends.search.fallback ?? 'off' : 'off',
208
- values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? ['off', 'duckduckgo'] : ['off']
209
- },
210
- {
211
- id: 'backend:secret:brave',
212
- label: 'Brave API key',
213
- currentValue: 'env var',
214
- values: ['env var']
215
- },
216
- {
217
- id: 'backend:fetch:provider',
218
- label: 'Fetch backend',
219
- currentValue: backends.fetch.provider,
220
- values: ['http', 'firecrawl']
221
- },
222
- {
223
- id: 'backend:fetch:baseUrl',
224
- label: 'Firecrawl URL',
225
- currentValue: backends.fetch.baseUrl ?? 'not set',
226
- values: ['edit']
227
- },
228
- {
229
- id: 'backend:fetch:fallback',
230
- label: 'Firecrawl fallback',
231
- currentValue: backends.fetch.provider === 'firecrawl' ? backends.fetch.fallback ?? 'off' : 'off',
232
- values: backends.fetch.provider === 'firecrawl' ? ['off', 'http'] : ['off']
233
- },
234
- {
235
- id: 'backend:secret:firecrawl',
236
- label: 'Firecrawl API key',
237
- currentValue: 'env var',
238
- values: ['env var']
239
- }
240
- ];
241
- }
242
-
243
- function isToolName(value: string): value is PresentationToolName {
244
- return PRESENTATION_TOOL_NAMES.includes(value as PresentationToolName);
245
- }
246
-
247
- function isModeOrInherit(value: string): value is 'inherit' | 'compact' | 'preview' | 'verbose' {
248
- return ['inherit', 'compact', 'preview', 'verbose'].includes(value);
249
- }
250
-
251
- export function getInheritedConfigForScope(
252
- loaded: Awaited<LoadedPresentationConfig>,
253
- scope: PresentationScope
254
- ): PresentationConfig {
255
- if (scope === 'global') {
256
- return DEFAULT_PRESENTATION_CONFIG;
257
- }
258
-
259
- return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
260
- }
261
-
262
- export function getScopeDisplayConfig(
263
- loaded: Awaited<LoadedPresentationConfig>,
264
- scope: PresentationScope
265
- ): PresentationConfig {
266
- if (scope === 'global') {
267
- return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
268
- }
269
-
270
- return mergePresentationConfigLayers(
271
- DEFAULT_PRESENTATION_CONFIG,
272
- loaded.global.rawConfig,
273
- loaded.project.rawConfig
274
- );
275
- }
276
-
277
- export function getInheritedBackendsForScope(
278
- loaded: Awaited<LoadedPresentationConfig>,
279
- scope: PresentationScope
280
- ): BackendConfig {
281
- if (scope === 'global') {
282
- return DEFAULT_BACKEND_CONFIG;
283
- }
284
-
285
- return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
286
- }
287
-
288
- export function getScopeDisplayBackends(
289
- loaded: Awaited<LoadedPresentationConfig>,
290
- scope: PresentationScope
291
- ): BackendConfig {
292
- if (scope === 'global') {
293
- return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
294
- }
295
-
296
- return mergeBackendConfigLayers(
297
- DEFAULT_BACKEND_CONFIG,
298
- loaded.global.rawBackends,
299
- loaded.project.rawBackends
300
- );
301
- }
302
-
303
- export function createSettingsDraftState(
304
- loaded: Awaited<LoadedPresentationConfig>,
305
- initialScope: PresentationScope
306
- ): SettingsDraftState {
307
- const drafts = {
308
- global: getScopeDisplayConfig(loaded, 'global'),
309
- project: getScopeDisplayConfig(loaded, 'project')
310
- };
311
- const backendDrafts = {
312
- global: getScopeDisplayBackends(loaded, 'global'),
313
- project: getScopeDisplayBackends(loaded, 'project')
314
- };
315
-
316
- return {
317
- scope: initialScope,
318
- drafts,
319
- backendDrafts,
320
- config: clonePresentationConfig(drafts[initialScope]),
321
- backends: cloneBackendConfig(backendDrafts[initialScope])
322
- };
323
- }
324
-
325
- export function applySettingsValue(
326
- state: SettingsDraftState,
327
- id: string,
328
- newValue: string
329
- ): SettingsDraftState {
330
- const nextDrafts = {
331
- global: clonePresentationConfig(state.drafts.global),
332
- project: clonePresentationConfig(state.drafts.project)
333
- };
334
- const nextBackendDrafts = {
335
- global: cloneBackendConfig(state.backendDrafts.global),
336
- project: cloneBackendConfig(state.backendDrafts.project)
337
- };
338
-
339
- let nextScope = state.scope;
340
-
341
- if (id === 'scope' && (newValue === 'project' || newValue === 'global')) {
342
- nextScope = newValue;
343
- return {
344
- scope: nextScope,
345
- drafts: nextDrafts,
346
- backendDrafts: nextBackendDrafts,
347
- config: clonePresentationConfig(nextDrafts[nextScope]),
348
- backends: cloneBackendConfig(nextBackendDrafts[nextScope])
349
- };
350
- }
351
-
352
- const currentDraft = clonePresentationConfig(nextDrafts[nextScope]);
353
- const currentBackends = cloneBackendConfig(nextBackendDrafts[nextScope]);
354
-
355
- if (id === 'defaultMode' && (newValue === 'compact' || newValue === 'preview' || newValue === 'verbose')) {
356
- currentDraft.defaultMode = newValue;
357
- }
358
-
359
- if (id.startsWith('tool:')) {
360
- const toolName = id.slice('tool:'.length) as PresentationToolName;
361
- const nextTools = { ...currentDraft.tools };
362
-
363
- if (newValue === 'inherit') {
364
- delete nextTools[toolName];
365
- } else if (
366
- newValue === 'compact' ||
367
- newValue === 'preview' ||
368
- newValue === 'verbose'
369
- ) {
370
- nextTools[toolName] = { mode: newValue };
371
- }
372
-
373
- currentDraft.tools = nextTools;
374
- }
375
-
376
- if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave')) {
377
- currentBackends.search.provider = newValue;
378
- if (newValue !== 'searxng') {
379
- delete currentBackends.search.baseUrl;
380
- delete currentBackends.search.options;
381
- }
382
- if (newValue === 'duckduckgo') {
383
- delete currentBackends.search.fallback;
384
- }
385
- }
386
-
387
- if (id === 'backend:search:fallback') {
388
- if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave')) {
389
- currentBackends.search.fallback = 'duckduckgo';
390
- } else {
391
- delete currentBackends.search.fallback;
392
- }
393
- }
394
-
395
- if (id === 'backend:search:baseUrl') {
396
- if (currentBackends.search.provider === 'searxng' && newValue.trim()) {
397
- currentBackends.search.baseUrl = newValue.trim();
398
- } else {
399
- delete currentBackends.search.baseUrl;
400
- }
401
- }
402
-
403
- if (id === 'backend:fetch:provider' && (newValue === 'http' || newValue === 'firecrawl')) {
404
- currentBackends.fetch.provider = newValue;
405
- if (newValue === 'http') {
406
- delete currentBackends.fetch.fallback;
407
- }
408
- }
409
-
410
- if (id === 'backend:fetch:fallback') {
411
- if (newValue === 'http' && currentBackends.fetch.provider === 'firecrawl') {
412
- currentBackends.fetch.fallback = 'http';
413
- } else if (newValue === 'off' || currentBackends.fetch.provider !== 'firecrawl') {
414
- delete currentBackends.fetch.fallback;
415
- }
416
- }
417
-
418
- if (id === 'backend:fetch:baseUrl') {
419
- if (newValue.trim()) {
420
- currentBackends.fetch.baseUrl = newValue.trim();
421
- } else {
422
- delete currentBackends.fetch.baseUrl;
423
- }
424
- }
425
-
426
- nextDrafts[nextScope] = currentDraft;
427
- nextBackendDrafts[nextScope] = currentBackends;
428
-
429
- return {
430
- scope: nextScope,
431
- drafts: nextDrafts,
432
- backendDrafts: nextBackendDrafts,
433
- config: clonePresentationConfig(nextDrafts[nextScope]),
434
- backends: cloneBackendConfig(nextBackendDrafts[nextScope])
435
- };
436
- }
437
-
438
- export function collapsePresentationConfigToOverride(
439
- config: PresentationConfig,
440
- inheritedConfig: PresentationConfig
441
- ): PresentationConfigOverride {
442
- const tools = Object.fromEntries(
443
- PRESENTATION_TOOL_NAMES.flatMap((toolName) => {
444
- const configuredMode = config.tools[toolName]?.mode;
445
- if (!configuredMode) {
446
- return [];
447
- }
448
-
449
- const inheritedMode = resolvePresentationMode(toolName, inheritedConfig);
450
- if (configuredMode === inheritedMode) {
451
- return [];
452
- }
453
-
454
- return [[toolName, { mode: configuredMode }]];
455
- })
456
- ) as PresentationConfigOverride['tools'];
457
-
458
- return {
459
- defaultMode:
460
- config.defaultMode === inheritedConfig.defaultMode ? undefined : config.defaultMode,
461
- tools
462
- };
463
- }
464
-
465
- export function collapseBackendConfigToOverride(
466
- config: BackendConfig,
467
- inheritedConfig: BackendConfig
468
- ): BackendConfigOverride {
469
- const override: BackendConfigOverride = {};
470
-
471
- if (!sameJson(config.search, inheritedConfig.search)) {
472
- override.search = config.search.provider !== inheritedConfig.search.provider
473
- ? { ...config.search }
474
- : {
475
- ...(config.search.baseUrl !== inheritedConfig.search.baseUrl ? { baseUrl: config.search.baseUrl } : {}),
476
- ...(config.search.fallback !== inheritedConfig.search.fallback ? { fallback: config.search.fallback } : {}),
477
- ...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {})
478
- };
479
-
480
- if (config.search.provider !== inheritedConfig.search.provider) {
481
- override.search.provider = config.search.provider;
482
- } else if (Object.keys(override.search).length === 0) {
483
- delete override.search;
484
- }
485
- }
486
-
487
- if (!sameJson(config.fetch, inheritedConfig.fetch)) {
488
- override.fetch = config.fetch.provider !== inheritedConfig.fetch.provider
489
- ? { ...config.fetch, apiKey: undefined }
490
- : {
491
- ...(config.fetch.baseUrl !== inheritedConfig.fetch.baseUrl ? { baseUrl: config.fetch.baseUrl } : {}),
492
- ...(config.fetch.fallback !== inheritedConfig.fetch.fallback ? { fallback: config.fetch.fallback } : {}),
493
- ...(!sameJson(config.fetch.options, inheritedConfig.fetch.options) ? { options: config.fetch.options } : {})
494
- };
495
-
496
- delete override.fetch.apiKey;
497
-
498
- if (config.fetch.provider !== inheritedConfig.fetch.provider) {
499
- override.fetch.provider = config.fetch.provider;
500
- } else if (Object.keys(override.fetch).length === 0) {
501
- delete override.fetch;
502
- }
503
- }
504
-
505
- if (!sameJson(config.headless, inheritedConfig.headless)) {
506
- override.headless = { ...config.headless };
507
- }
508
-
509
- return override;
510
- }
511
-
512
- export function handleSettingsShortcut(data: string): { action: 'cancel' | 'reset' | 'save' } | undefined {
513
- if (data === '\u001b') {
514
- return { action: 'cancel' };
515
- }
516
-
517
- if (data === '\u0012') {
518
- return { action: 'reset' };
519
- }
520
-
521
- if (data === '\u0013') {
522
- return { action: 'save' };
523
- }
524
-
525
- return undefined;
526
- }
527
-
528
- async function openActionMenu(ctx: any): Promise<WebAgentAction | undefined> {
529
- return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: WebAgentAction | undefined) => void) => {
530
- const container = new Container();
531
- const items: SelectItem[] = [
532
- { value: 'settings', label: 'Settings', description: 'Edit presentation modes and backends' },
533
- { value: 'show', label: 'Show config', description: 'Print effective config paths and modes' },
534
- { value: 'doctor', label: 'Doctor', description: 'Check runtime dependencies and browser detection' },
535
- { value: 'changelog', label: 'Changelog', description: 'Show latest package changelog' },
536
- { value: 'reset-project', label: 'Reset project config', description: 'Delete project-level overrides' },
537
- { value: 'reset-global', label: 'Reset global config', description: 'Delete global overrides' }
538
- ];
539
-
540
- container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
541
- container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent')), 1, 0));
542
-
543
- const list = new SelectList(items, Math.min(items.length, 8), {
544
- selectedPrefix: (text: string) => theme.fg('accent', text),
545
- selectedText: (text: string) => theme.fg('accent', text),
546
- description: (text: string) => theme.fg('muted', text),
547
- scrollInfo: (text: string) => theme.fg('dim', text),
548
- noMatch: (text: string) => theme.fg('warning', text)
549
- });
550
-
551
- list.onSelect = (item) => done(item.value as WebAgentAction);
552
- list.onCancel = () => done(undefined);
553
- container.addChild(list);
554
- container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0));
555
- container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
556
-
557
- return {
558
- render: (width: number) => container.render(width),
559
- invalidate: () => container.invalidate(),
560
- handleInput: (data: string) => {
561
- list.handleInput?.(data);
562
- tui.requestRender?.();
563
- }
564
- };
565
- });
566
- }
567
-
568
- async function openSettingsSectionMenu(ctx: any): Promise<SettingsSection | undefined> {
569
- return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: SettingsSection | undefined) => void) => {
570
- const container = new Container();
571
- const items: SelectItem[] = [
572
- { value: 'presentation', label: 'Presentation', description: 'Compact, preview, and verbose output modes' },
573
- { value: 'backends', label: 'Backends', description: 'Search/fetch providers, URLs, and fallbacks' }
574
- ];
575
-
576
- container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
577
- container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent settings')), 1, 0));
578
-
579
- const list = new SelectList(items, items.length, {
580
- selectedPrefix: (text: string) => theme.fg('accent', text),
581
- selectedText: (text: string) => theme.fg('accent', text),
582
- description: (text: string) => theme.fg('muted', text),
583
- scrollInfo: (text: string) => theme.fg('dim', text),
584
- noMatch: (text: string) => theme.fg('warning', text)
585
- });
586
-
587
- list.onSelect = (item) => done(item.value as SettingsSection);
588
- list.onCancel = () => done(undefined);
589
- container.addChild(list);
590
- container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0));
591
- container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
592
-
593
- return {
594
- render: (width: number) => container.render(width),
595
- invalidate: () => container.invalidate(),
596
- handleInput: (data: string) => {
597
- list.handleInput?.(data);
598
- tui.requestRender?.();
599
- }
600
- };
601
- });
602
- }
603
-
604
- async function openPresentationSettingsUi(
605
- ctx: any,
606
- loaded: Awaited<LoadedPresentationConfig>,
607
- initialScope: PresentationScope
608
- ): Promise<SettingsUiResult | undefined> {
609
- return ctx.ui.custom((_tui: unknown, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
610
- let state = createSettingsDraftState(loaded, initialScope);
611
- let settingsList: SettingsList;
612
-
613
- const container = new Container();
614
- container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · presentation')), 1, 1));
615
- container.addChild(
616
- new Text(
617
- theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel'),
618
- 1,
619
- 2
620
- )
621
- );
622
-
623
- const rebuildSettingsList = () => {
624
- if (settingsList) {
625
- container.removeChild(settingsList);
626
- }
627
-
628
- settingsList = new SettingsList(
629
- buildPresentationSettingsItems(state.scope, state.config),
630
- Math.min(PRESENTATION_TOOL_NAMES.length + 8, 18),
631
- getSettingsListTheme(),
632
- (id, newValue) => {
633
- state = applySettingsValue(state, id, newValue);
634
- rebuildSettingsList();
635
- container.invalidate();
636
- },
637
- () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }),
638
- { enableSearch: true }
639
- );
640
-
641
- container.addChild(settingsList);
642
- };
643
-
644
- rebuildSettingsList();
645
-
646
- return {
647
- render: (width: number) => container.render(width),
648
- invalidate: () => container.invalidate(),
649
- handleInput: (data: string) => {
650
- const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
651
-
652
- if (shortcut?.action === 'cancel') {
653
- done({ action: 'cancel' });
654
- return;
655
- }
656
-
657
- if (shortcut?.action === 'reset') {
658
- done({ action: 'reset', scope: state.scope });
659
- return;
660
- }
661
-
662
- if (shortcut?.action === 'save') {
663
- done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
664
- return;
665
- }
666
-
667
- settingsList.handleInput?.(data);
668
- }
669
- };
670
- });
671
- }
672
-
673
- async function openBackendSettingsUi(
674
- ctx: any,
675
- loaded: Awaited<LoadedPresentationConfig>,
676
- initialScope: PresentationScope
677
- ): Promise<SettingsUiResult | undefined> {
678
- return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
679
- let state = createSettingsDraftState(loaded, initialScope);
680
- let settingsList: SettingsList;
681
-
682
- const container = new Container();
683
- container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · backends')), 1, 1));
684
- container.addChild(
685
- new Text(
686
- theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel · API keys stay in env vars'),
687
- 1,
688
- 2
689
- )
690
- );
691
-
692
- const editUrl = async (id: string) => {
693
- const isSearchUrl = id === 'backend:search:baseUrl';
694
- const label = isSearchUrl ? 'SearXNG base URL' : 'Firecrawl base URL';
695
- const currentValue = isSearchUrl ? state.backends.search.baseUrl : state.backends.fetch.baseUrl;
696
- const entered = await ctx.ui.input(label, currentValue ?? (isSearchUrl ? 'http://localhost:8080' : 'http://localhost:3002'));
697
- if (entered === undefined) return;
698
-
699
- if (!entered.trim()) {
700
- state = applySettingsValue(state, id, '');
701
- rebuildSettingsList();
702
- tui.requestRender?.();
703
- return;
704
- }
705
-
706
- const validated = validateBackendUrl(entered);
707
- if (!validated.ok) {
708
- ctx.ui.notify(validated.message, 'warning');
709
- return;
710
- }
711
-
712
- state = applySettingsValue(state, id, validated.value);
713
- rebuildSettingsList();
714
- tui.requestRender?.();
715
- };
716
-
717
- const rebuildSettingsList = () => {
718
- if (settingsList) {
719
- container.removeChild(settingsList);
720
- }
721
-
722
- settingsList = new SettingsList(
723
- buildBackendSettingsItems(state.scope, state.backends),
724
- 12,
725
- getSettingsListTheme(),
726
- (id, newValue) => {
727
- if (id === 'backend:search:baseUrl' || id === 'backend:fetch:baseUrl') {
728
- void editUrl(id);
729
- return;
730
- }
731
-
732
- state = applySettingsValue(state, id, newValue);
733
- rebuildSettingsList();
734
- container.invalidate();
735
- },
736
- () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }),
737
- { enableSearch: true }
738
- );
739
-
740
- container.addChild(settingsList);
741
- };
742
-
743
- rebuildSettingsList();
744
-
745
- return {
746
- render: (width: number) => container.render(width),
747
- invalidate: () => container.invalidate(),
748
- handleInput: (data: string) => {
749
- const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
750
-
751
- if (shortcut?.action === 'cancel') {
752
- done({ action: 'cancel' });
753
- return;
754
- }
755
-
756
- if (shortcut?.action === 'reset') {
757
- done({ action: 'reset', scope: state.scope });
758
- return;
759
- }
760
-
761
- if (shortcut?.action === 'save') {
762
- done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
763
- return;
764
- }
765
-
766
- settingsList.handleInput?.(data);
767
- }
768
- };
769
- });
770
- }
771
-
772
- export function registerWebAgentConfigCommands(pi: ExtensionAPI, deps: CommandDeps = {}) {
773
- const load = deps.load ?? (() => loadPresentationConfigLayers());
774
- const save = deps.save ?? ((scope, config) => savePresentationConfigScope({}, scope, config));
775
- const saveBackends = deps.saveBackends ?? ((scope, config) => saveBackendConfigScope({}, scope, config));
776
- const reset = deps.reset ?? ((scope) => resetPresentationConfigScope({}, scope));
777
- const resolveBrowser = deps.resolveBrowser ?? (() => resolveBrowserExecutable({}));
778
- const runtime = deps.runtime ?? {
779
- nodeVersion: process.version,
780
- platform: process.platform,
781
- arch: process.arch
782
- };
783
- const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
784
- const checkBackends = deps.checkBackends ?? ((config: BackendConfig) => checkBackendHealth(config));
785
- const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
786
-
787
- pi.registerCommand('web-agent', {
788
- description: 'Open settings or manage pi-web-agent presentation config',
789
- handler: async (args, ctx) => {
790
- let [action, maybeScope] = (args ?? '').trim().split(/\s+/).filter(Boolean);
791
-
792
- if (!action) {
793
- const selectedAction = await openActionMenu(ctx);
794
- if (!selectedAction) return;
795
-
796
- if (selectedAction === 'reset-project') {
797
- action = 'reset';
798
- maybeScope = 'project';
799
- } else if (selectedAction === 'reset-global') {
800
- action = 'reset';
801
- maybeScope = 'global';
802
- } else {
803
- action = selectedAction;
804
- }
805
- }
806
-
807
- if (action === 'doctor') {
808
- const [typeboxOk, browser, loaded] = await Promise.all([checkTypebox(), resolveBrowser(), load()]);
809
- const backendConfig = loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG;
810
- const backendIssues = validateBackendConfig(backendConfig);
811
- const backendHealth = await checkBackends(backendConfig);
812
- const lines = [
813
- 'pi-web-agent: loaded',
814
- `runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
815
- `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
816
- formatBackendSummary(backendConfig),
817
- backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
818
- ...backendHealth
819
- ];
820
-
821
- if (browser.ok) {
822
- lines.push(`browser: ${browser.browser} ${browser.executablePath}`);
823
- } else {
824
- lines.push(`browser: missing (${browser.error.code})`);
825
- lines.push(browser.error.message);
826
- lines.push('Install Chrome, Chromium, Edge, or Brave and run /web-agent doctor again.');
827
- }
828
-
829
- ctx.ui.notify(lines.join('\n'), 'info');
830
- return;
831
- }
832
-
833
- if (action === 'show') {
834
- const loaded = await load();
835
- ctx.ui.notify(
836
- [
837
- formatConfigSummary(loaded.effectiveConfig),
838
- formatBackendSummary(loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG),
839
- `global: ${loaded.global.path}${loaded.global.exists ? '' : ' (missing)'}`,
840
- `project: ${loaded.project.path}${loaded.project.exists ? '' : ' (missing)'}`
841
- ].join('\n'),
842
- 'info'
843
- );
844
- return;
845
- }
846
-
847
- if (action === 'changelog') {
848
- const changelog = await getChangelog();
849
- ctx.ui.notify(changelog ?? 'No pi-web-agent changelog entries found.', 'info');
850
- return;
851
- }
852
-
853
- if (action === 'reset') {
854
- const scope = parseScopeToken(maybeScope) ?? 'project';
855
- await reset(scope);
856
- ctx.ui.notify(`Reset ${scope} config`, 'info');
857
- return;
858
- }
859
-
860
- if (action === 'mode') {
861
- const [, first, second] = (args ?? '').trim().split(/\s+/).filter(Boolean);
862
- const loaded = await load();
863
- const scope: PresentationScope = 'project';
864
- const baseConfig = getScopeDisplayConfig(loaded, scope);
865
- const inheritedConfig = getInheritedConfigForScope(loaded, scope);
866
-
867
- if (first && isModeOrInherit(first) && first !== 'inherit') {
868
- await save(scope, collapsePresentationConfigToOverride({ ...baseConfig, defaultMode: first }, inheritedConfig));
869
- ctx.ui.notify(`Saved project default mode = ${first}`, 'info');
870
- return;
871
- }
872
-
873
- if (first && second && isToolName(first) && isModeOrInherit(second)) {
874
- const nextTools = { ...baseConfig.tools };
875
-
876
- if (second === 'inherit') {
877
- delete nextTools[first];
878
- } else {
879
- nextTools[first] = { mode: second };
880
- }
881
-
882
- await save(
883
- scope,
884
- collapsePresentationConfigToOverride({ ...baseConfig, tools: nextTools }, inheritedConfig)
885
- );
886
- ctx.ui.notify(`Saved project ${first} = ${second}`, 'info');
887
- return;
888
- }
889
-
890
- ctx.ui.notify(
891
- 'Usage: /web-agent mode <compact|preview|verbose> or /web-agent mode <tool> <inherit|compact|preview|verbose>',
892
- 'info'
893
- );
894
- return;
895
- }
896
-
897
- if (!action || action === 'settings') {
898
- const loaded = await load();
899
- const initialScope: PresentationScope = 'project';
900
- const section = await openSettingsSectionMenu(ctx);
901
- if (!section) return;
902
-
903
- const result = section === 'presentation'
904
- ? await openPresentationSettingsUi(ctx, loaded, initialScope)
905
- : await openBackendSettingsUi(ctx, loaded, initialScope);
906
-
907
- if (!result || result.action === 'cancel') {
908
- return;
909
- }
910
-
911
- if (result.action === 'reset') {
912
- await reset(result.scope);
913
- ctx.ui.notify(`Reset ${result.scope} config`, 'info');
914
- return;
915
- }
916
-
917
- if (section === 'presentation') {
918
- await save(
919
- result.scope,
920
- collapsePresentationConfigToOverride(
921
- result.config,
922
- getInheritedConfigForScope(loaded, result.scope)
923
- )
924
- );
925
- ctx.ui.notify(`Saved ${result.scope} presentation config`, 'info');
926
- return;
927
- }
928
-
929
- await saveBackends(
930
- result.scope,
931
- collapseBackendConfigToOverride(
932
- result.backends,
933
- getInheritedBackendsForScope(loaded, result.scope)
934
- )
935
- );
936
- ctx.ui.notify(`Saved ${result.scope} backend config`, 'info');
937
- return;
938
- }
939
-
940
- ctx.ui.notify(
941
- 'Use /web-agent, /web-agent show, /web-agent doctor, /web-agent changelog, /web-agent reset project, or /web-agent settings',
942
- 'info'
943
- );
944
- }
945
- });
946
- }
1
+ import {
2
+ DEFAULT_BACKEND_CONFIG,
3
+ mergeBackendConfigLayers,
4
+ validateBackendConfig,
5
+ type BackendConfig,
6
+ type BackendConfigOverride
7
+ } from '../backends/config.js';
8
+ import { checkBackendHealth } from '../backends/doctor.js';
9
+ import {
10
+ DynamicBorder,
11
+ getSettingsListTheme,
12
+ type ExtensionAPI
13
+ } from '@selesai/code';
14
+ import { Container, SelectList, SettingsList, Text, type SelectItem, type SettingItem } from '@earendil-works/pi-tui';
15
+ import {
16
+ DEFAULT_PRESENTATION_CONFIG,
17
+ mergePresentationConfigLayers,
18
+ resolvePresentationMode
19
+ } from '../presentation/config.js';
20
+ import {
21
+ loadPresentationConfigLayers,
22
+ resetPresentationConfigScope,
23
+ saveBackendConfigScope,
24
+ savePresentationConfigScope,
25
+ type LoadedPresentationConfig
26
+ } from '../presentation/config-store.js';
27
+ import { resolveBrowserExecutable, type BrowserResolutionResult } from '../fetch/browser-resolution.js';
28
+ import { getLatestChangelogEntry } from '../changelog-notice.js';
29
+ import type {
30
+ PresentationConfig,
31
+ PresentationConfigOverride,
32
+ PresentationScope,
33
+ PresentationToolName
34
+ } from '../presentation/types.js';
35
+
36
+ type CommandDeps = {
37
+ load?: () => ReturnType<typeof loadPresentationConfigLayers>;
38
+ save?: (scope: PresentationScope, config: PresentationConfigOverride) => Promise<void>;
39
+ saveBackends?: (scope: PresentationScope, config: BackendConfigOverride) => Promise<void>;
40
+ reset?: (scope: PresentationScope) => Promise<void>;
41
+ resolveBrowser?: () => Promise<BrowserResolutionResult>;
42
+ runtime?: { nodeVersion: string; platform: string; arch: string };
43
+ checkTypebox?: () => Promise<boolean>;
44
+ checkBackends?: (config: BackendConfig) => Promise<string[]>;
45
+ getChangelog?: () => Promise<string | undefined>;
46
+ };
47
+
48
+ type SettingsUiResult =
49
+ | { action: 'cancel' }
50
+ | { action: 'reset'; scope: PresentationScope }
51
+ | { action: 'save'; scope: PresentationScope; config: PresentationConfig; backends: BackendConfig };
52
+
53
+ type WebAgentAction = 'settings' | 'show' | 'doctor' | 'changelog' | 'reset-project' | 'reset-global';
54
+ type SettingsSection = 'presentation' | 'backends';
55
+
56
+ export type SettingsDraftState = {
57
+ scope: PresentationScope;
58
+ drafts: Record<PresentationScope, PresentationConfig>;
59
+ backendDrafts: Record<PresentationScope, BackendConfig>;
60
+ config: PresentationConfig;
61
+ backends: BackendConfig;
62
+ };
63
+
64
+ const PRESENTATION_TOOL_NAMES: PresentationToolName[] = ['web_explore'];
65
+
66
+ function parseScopeToken(token: string | undefined): PresentationScope | undefined {
67
+ return token === 'global' || token === 'project' ? token : undefined;
68
+ }
69
+
70
+ function clonePresentationConfig(config: PresentationConfig): PresentationConfig {
71
+ return {
72
+ defaultMode: config.defaultMode,
73
+ tools: { ...config.tools }
74
+ };
75
+ }
76
+
77
+ function cloneBackendConfig(config: BackendConfig): BackendConfig {
78
+ return {
79
+ search: {
80
+ ...config.search,
81
+ options: config.search.options ? { ...config.search.options } : undefined
82
+ },
83
+ fetch: {
84
+ ...config.fetch,
85
+ options: config.fetch.options ? { ...config.fetch.options } : undefined
86
+ },
87
+ headless: { ...config.headless }
88
+ };
89
+ }
90
+
91
+ function sameJson(left: unknown, right: unknown): boolean {
92
+ return JSON.stringify(left) === JSON.stringify(right);
93
+ }
94
+
95
+ export function validateBackendUrl(value: string): { ok: true; value: string } | { ok: false; message: string } {
96
+ try {
97
+ const url = new URL(value.trim());
98
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
99
+ return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
100
+ }
101
+
102
+ return { ok: true, value: url.toString().replace(/\/$/, '') };
103
+ } catch {
104
+ return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
105
+ }
106
+ }
107
+
108
+ async function defaultCheckTypebox(): Promise<boolean> {
109
+ try {
110
+ await import('typebox');
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function formatSearchOptions(config: BackendConfig['search']) {
118
+ return [
119
+ config.fallback ? `fallback ${config.fallback}` : undefined,
120
+ config.options?.categories?.length ? `categories ${config.options.categories.join(',')}` : undefined,
121
+ config.options?.language ? `language ${config.options.language}` : undefined,
122
+ config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined
123
+ ].filter(Boolean).join(' ');
124
+ }
125
+
126
+ function formatFetchOptions(config: BackendConfig['fetch']) {
127
+ return [
128
+ config.fallback ? `fallback ${config.fallback}` : undefined,
129
+ config.options?.formats?.length ? `formats ${config.options.formats.join(',')}` : undefined,
130
+ config.options?.onlyMainContent !== undefined ? `onlyMainContent ${config.options.onlyMainContent}` : undefined
131
+ ].filter(Boolean).join(' ');
132
+ }
133
+
134
+ function formatBackendSummary(config: BackendConfig = DEFAULT_BACKEND_CONFIG) {
135
+ const searchSuffix = formatSearchOptions(config.search);
136
+ const fetchSuffix = formatFetchOptions(config.fetch);
137
+ const searchBase = config.search.baseUrl
138
+ ? `search: ${config.search.provider} (${config.search.baseUrl})`
139
+ : `search: ${config.search.provider}`;
140
+ const fetchBase = config.fetch.baseUrl
141
+ ? `fetch: ${config.fetch.provider} (${config.fetch.baseUrl})`
142
+ : `fetch: ${config.fetch.provider}`;
143
+
144
+ return [
145
+ searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
146
+ fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
147
+ `headless: ${config.headless.provider}`
148
+ ].join('\n');
149
+ }
150
+
151
+ function formatConfigSummary(config: PresentationConfig) {
152
+ const lines = [`defaultMode: ${config.defaultMode}`];
153
+
154
+ for (const toolName of PRESENTATION_TOOL_NAMES) {
155
+ lines.push(`${toolName}: ${config.tools[toolName]?.mode ?? 'inherit'}`);
156
+ }
157
+
158
+ return lines.join('\n');
159
+ }
160
+
161
+ function buildPresentationSettingsItems(scope: PresentationScope, config: PresentationConfig): SettingItem[] {
162
+ return [
163
+ {
164
+ id: 'scope',
165
+ label: 'Write scope',
166
+ currentValue: scope,
167
+ values: ['project', 'global']
168
+ },
169
+ {
170
+ id: 'defaultMode',
171
+ label: 'Default mode',
172
+ currentValue: config.defaultMode,
173
+ values: ['compact', 'preview', 'verbose']
174
+ },
175
+ ...PRESENTATION_TOOL_NAMES.map((toolName) => ({
176
+ id: `tool:${toolName}`,
177
+ label: toolName,
178
+ currentValue: config.tools[toolName]?.mode ?? 'inherit',
179
+ values: ['inherit', 'compact', 'preview', 'verbose']
180
+ }))
181
+ ];
182
+ }
183
+
184
+ function buildBackendSettingsItems(scope: PresentationScope, backends: BackendConfig): SettingItem[] {
185
+ return [
186
+ {
187
+ id: 'scope',
188
+ label: 'Write scope',
189
+ currentValue: scope,
190
+ values: ['project', 'global']
191
+ },
192
+ {
193
+ id: 'backend:search:provider',
194
+ label: 'Search backend',
195
+ currentValue: backends.search.provider,
196
+ values: ['duckduckgo', 'searxng', 'brave']
197
+ },
198
+ {
199
+ id: 'backend:search:baseUrl',
200
+ label: 'SearXNG URL',
201
+ currentValue: backends.search.baseUrl ?? 'not set',
202
+ values: ['edit']
203
+ },
204
+ {
205
+ id: 'backend:search:fallback',
206
+ label: 'Search fallback',
207
+ currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? backends.search.fallback ?? 'off' : 'off',
208
+ values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' ? ['off', 'duckduckgo'] : ['off']
209
+ },
210
+ {
211
+ id: 'backend:secret:brave',
212
+ label: 'Brave API key',
213
+ currentValue: 'env var',
214
+ values: ['env var']
215
+ },
216
+ {
217
+ id: 'backend:fetch:provider',
218
+ label: 'Fetch backend',
219
+ currentValue: backends.fetch.provider,
220
+ values: ['http', 'firecrawl']
221
+ },
222
+ {
223
+ id: 'backend:fetch:baseUrl',
224
+ label: 'Firecrawl URL',
225
+ currentValue: backends.fetch.baseUrl ?? 'not set',
226
+ values: ['edit']
227
+ },
228
+ {
229
+ id: 'backend:fetch:fallback',
230
+ label: 'Firecrawl fallback',
231
+ currentValue: backends.fetch.provider === 'firecrawl' ? backends.fetch.fallback ?? 'off' : 'off',
232
+ values: backends.fetch.provider === 'firecrawl' ? ['off', 'http'] : ['off']
233
+ },
234
+ {
235
+ id: 'backend:secret:firecrawl',
236
+ label: 'Firecrawl API key',
237
+ currentValue: 'env var',
238
+ values: ['env var']
239
+ }
240
+ ];
241
+ }
242
+
243
+ function isToolName(value: string): value is PresentationToolName {
244
+ return PRESENTATION_TOOL_NAMES.includes(value as PresentationToolName);
245
+ }
246
+
247
+ function isModeOrInherit(value: string): value is 'inherit' | 'compact' | 'preview' | 'verbose' {
248
+ return ['inherit', 'compact', 'preview', 'verbose'].includes(value);
249
+ }
250
+
251
+ export function getInheritedConfigForScope(
252
+ loaded: Awaited<LoadedPresentationConfig>,
253
+ scope: PresentationScope
254
+ ): PresentationConfig {
255
+ if (scope === 'global') {
256
+ return DEFAULT_PRESENTATION_CONFIG;
257
+ }
258
+
259
+ return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
260
+ }
261
+
262
+ export function getScopeDisplayConfig(
263
+ loaded: Awaited<LoadedPresentationConfig>,
264
+ scope: PresentationScope
265
+ ): PresentationConfig {
266
+ if (scope === 'global') {
267
+ return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
268
+ }
269
+
270
+ return mergePresentationConfigLayers(
271
+ DEFAULT_PRESENTATION_CONFIG,
272
+ loaded.global.rawConfig,
273
+ loaded.project.rawConfig
274
+ );
275
+ }
276
+
277
+ export function getInheritedBackendsForScope(
278
+ loaded: Awaited<LoadedPresentationConfig>,
279
+ scope: PresentationScope
280
+ ): BackendConfig {
281
+ if (scope === 'global') {
282
+ return DEFAULT_BACKEND_CONFIG;
283
+ }
284
+
285
+ return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
286
+ }
287
+
288
+ export function getScopeDisplayBackends(
289
+ loaded: Awaited<LoadedPresentationConfig>,
290
+ scope: PresentationScope
291
+ ): BackendConfig {
292
+ if (scope === 'global') {
293
+ return mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, loaded.global.rawBackends);
294
+ }
295
+
296
+ return mergeBackendConfigLayers(
297
+ DEFAULT_BACKEND_CONFIG,
298
+ loaded.global.rawBackends,
299
+ loaded.project.rawBackends
300
+ );
301
+ }
302
+
303
+ export function createSettingsDraftState(
304
+ loaded: Awaited<LoadedPresentationConfig>,
305
+ initialScope: PresentationScope
306
+ ): SettingsDraftState {
307
+ const drafts = {
308
+ global: getScopeDisplayConfig(loaded, 'global'),
309
+ project: getScopeDisplayConfig(loaded, 'project')
310
+ };
311
+ const backendDrafts = {
312
+ global: getScopeDisplayBackends(loaded, 'global'),
313
+ project: getScopeDisplayBackends(loaded, 'project')
314
+ };
315
+
316
+ return {
317
+ scope: initialScope,
318
+ drafts,
319
+ backendDrafts,
320
+ config: clonePresentationConfig(drafts[initialScope]),
321
+ backends: cloneBackendConfig(backendDrafts[initialScope])
322
+ };
323
+ }
324
+
325
+ export function applySettingsValue(
326
+ state: SettingsDraftState,
327
+ id: string,
328
+ newValue: string
329
+ ): SettingsDraftState {
330
+ const nextDrafts = {
331
+ global: clonePresentationConfig(state.drafts.global),
332
+ project: clonePresentationConfig(state.drafts.project)
333
+ };
334
+ const nextBackendDrafts = {
335
+ global: cloneBackendConfig(state.backendDrafts.global),
336
+ project: cloneBackendConfig(state.backendDrafts.project)
337
+ };
338
+
339
+ let nextScope = state.scope;
340
+
341
+ if (id === 'scope' && (newValue === 'project' || newValue === 'global')) {
342
+ nextScope = newValue;
343
+ return {
344
+ scope: nextScope,
345
+ drafts: nextDrafts,
346
+ backendDrafts: nextBackendDrafts,
347
+ config: clonePresentationConfig(nextDrafts[nextScope]),
348
+ backends: cloneBackendConfig(nextBackendDrafts[nextScope])
349
+ };
350
+ }
351
+
352
+ const currentDraft = clonePresentationConfig(nextDrafts[nextScope]);
353
+ const currentBackends = cloneBackendConfig(nextBackendDrafts[nextScope]);
354
+
355
+ if (id === 'defaultMode' && (newValue === 'compact' || newValue === 'preview' || newValue === 'verbose')) {
356
+ currentDraft.defaultMode = newValue;
357
+ }
358
+
359
+ if (id.startsWith('tool:')) {
360
+ const toolName = id.slice('tool:'.length) as PresentationToolName;
361
+ const nextTools = { ...currentDraft.tools };
362
+
363
+ if (newValue === 'inherit') {
364
+ delete nextTools[toolName];
365
+ } else if (
366
+ newValue === 'compact' ||
367
+ newValue === 'preview' ||
368
+ newValue === 'verbose'
369
+ ) {
370
+ nextTools[toolName] = { mode: newValue };
371
+ }
372
+
373
+ currentDraft.tools = nextTools;
374
+ }
375
+
376
+ if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave')) {
377
+ currentBackends.search.provider = newValue;
378
+ if (newValue !== 'searxng') {
379
+ delete currentBackends.search.baseUrl;
380
+ delete currentBackends.search.options;
381
+ }
382
+ if (newValue === 'duckduckgo') {
383
+ delete currentBackends.search.fallback;
384
+ }
385
+ }
386
+
387
+ if (id === 'backend:search:fallback') {
388
+ if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave')) {
389
+ currentBackends.search.fallback = 'duckduckgo';
390
+ } else {
391
+ delete currentBackends.search.fallback;
392
+ }
393
+ }
394
+
395
+ if (id === 'backend:search:baseUrl') {
396
+ if (currentBackends.search.provider === 'searxng' && newValue.trim()) {
397
+ currentBackends.search.baseUrl = newValue.trim();
398
+ } else {
399
+ delete currentBackends.search.baseUrl;
400
+ }
401
+ }
402
+
403
+ if (id === 'backend:fetch:provider' && (newValue === 'http' || newValue === 'firecrawl')) {
404
+ currentBackends.fetch.provider = newValue;
405
+ if (newValue === 'http') {
406
+ delete currentBackends.fetch.fallback;
407
+ }
408
+ }
409
+
410
+ if (id === 'backend:fetch:fallback') {
411
+ if (newValue === 'http' && currentBackends.fetch.provider === 'firecrawl') {
412
+ currentBackends.fetch.fallback = 'http';
413
+ } else if (newValue === 'off' || currentBackends.fetch.provider !== 'firecrawl') {
414
+ delete currentBackends.fetch.fallback;
415
+ }
416
+ }
417
+
418
+ if (id === 'backend:fetch:baseUrl') {
419
+ if (newValue.trim()) {
420
+ currentBackends.fetch.baseUrl = newValue.trim();
421
+ } else {
422
+ delete currentBackends.fetch.baseUrl;
423
+ }
424
+ }
425
+
426
+ nextDrafts[nextScope] = currentDraft;
427
+ nextBackendDrafts[nextScope] = currentBackends;
428
+
429
+ return {
430
+ scope: nextScope,
431
+ drafts: nextDrafts,
432
+ backendDrafts: nextBackendDrafts,
433
+ config: clonePresentationConfig(nextDrafts[nextScope]),
434
+ backends: cloneBackendConfig(nextBackendDrafts[nextScope])
435
+ };
436
+ }
437
+
438
+ export function collapsePresentationConfigToOverride(
439
+ config: PresentationConfig,
440
+ inheritedConfig: PresentationConfig
441
+ ): PresentationConfigOverride {
442
+ const tools = Object.fromEntries(
443
+ PRESENTATION_TOOL_NAMES.flatMap((toolName) => {
444
+ const configuredMode = config.tools[toolName]?.mode;
445
+ if (!configuredMode) {
446
+ return [];
447
+ }
448
+
449
+ const inheritedMode = resolvePresentationMode(toolName, inheritedConfig);
450
+ if (configuredMode === inheritedMode) {
451
+ return [];
452
+ }
453
+
454
+ return [[toolName, { mode: configuredMode }]];
455
+ })
456
+ ) as PresentationConfigOverride['tools'];
457
+
458
+ return {
459
+ defaultMode:
460
+ config.defaultMode === inheritedConfig.defaultMode ? undefined : config.defaultMode,
461
+ tools
462
+ };
463
+ }
464
+
465
+ export function collapseBackendConfigToOverride(
466
+ config: BackendConfig,
467
+ inheritedConfig: BackendConfig
468
+ ): BackendConfigOverride {
469
+ const override: BackendConfigOverride = {};
470
+
471
+ if (!sameJson(config.search, inheritedConfig.search)) {
472
+ override.search = config.search.provider !== inheritedConfig.search.provider
473
+ ? { ...config.search }
474
+ : {
475
+ ...(config.search.baseUrl !== inheritedConfig.search.baseUrl ? { baseUrl: config.search.baseUrl } : {}),
476
+ ...(config.search.fallback !== inheritedConfig.search.fallback ? { fallback: config.search.fallback } : {}),
477
+ ...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {})
478
+ };
479
+
480
+ if (config.search.provider !== inheritedConfig.search.provider) {
481
+ override.search.provider = config.search.provider;
482
+ } else if (Object.keys(override.search).length === 0) {
483
+ delete override.search;
484
+ }
485
+ }
486
+
487
+ if (!sameJson(config.fetch, inheritedConfig.fetch)) {
488
+ override.fetch = config.fetch.provider !== inheritedConfig.fetch.provider
489
+ ? { ...config.fetch, apiKey: undefined }
490
+ : {
491
+ ...(config.fetch.baseUrl !== inheritedConfig.fetch.baseUrl ? { baseUrl: config.fetch.baseUrl } : {}),
492
+ ...(config.fetch.fallback !== inheritedConfig.fetch.fallback ? { fallback: config.fetch.fallback } : {}),
493
+ ...(!sameJson(config.fetch.options, inheritedConfig.fetch.options) ? { options: config.fetch.options } : {})
494
+ };
495
+
496
+ delete override.fetch.apiKey;
497
+
498
+ if (config.fetch.provider !== inheritedConfig.fetch.provider) {
499
+ override.fetch.provider = config.fetch.provider;
500
+ } else if (Object.keys(override.fetch).length === 0) {
501
+ delete override.fetch;
502
+ }
503
+ }
504
+
505
+ if (!sameJson(config.headless, inheritedConfig.headless)) {
506
+ override.headless = { ...config.headless };
507
+ }
508
+
509
+ return override;
510
+ }
511
+
512
+ export function handleSettingsShortcut(data: string): { action: 'cancel' | 'reset' | 'save' } | undefined {
513
+ if (data === '\u001b') {
514
+ return { action: 'cancel' };
515
+ }
516
+
517
+ if (data === '\u0012') {
518
+ return { action: 'reset' };
519
+ }
520
+
521
+ if (data === '\u0013') {
522
+ return { action: 'save' };
523
+ }
524
+
525
+ return undefined;
526
+ }
527
+
528
+ async function openActionMenu(ctx: any): Promise<WebAgentAction | undefined> {
529
+ return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: WebAgentAction | undefined) => void) => {
530
+ const container = new Container();
531
+ const items: SelectItem[] = [
532
+ { value: 'settings', label: 'Settings', description: 'Edit presentation modes and backends' },
533
+ { value: 'show', label: 'Show config', description: 'Print effective config paths and modes' },
534
+ { value: 'doctor', label: 'Doctor', description: 'Check runtime dependencies and browser detection' },
535
+ { value: 'changelog', label: 'Changelog', description: 'Show latest package changelog' },
536
+ { value: 'reset-project', label: 'Reset project config', description: 'Delete project-level overrides' },
537
+ { value: 'reset-global', label: 'Reset global config', description: 'Delete global overrides' }
538
+ ];
539
+
540
+ container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
541
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent')), 1, 0));
542
+
543
+ const list = new SelectList(items, Math.min(items.length, 8), {
544
+ selectedPrefix: (text: string) => theme.fg('accent', text),
545
+ selectedText: (text: string) => theme.fg('accent', text),
546
+ description: (text: string) => theme.fg('muted', text),
547
+ scrollInfo: (text: string) => theme.fg('dim', text),
548
+ noMatch: (text: string) => theme.fg('warning', text)
549
+ });
550
+
551
+ list.onSelect = (item) => done(item.value as WebAgentAction);
552
+ list.onCancel = () => done(undefined);
553
+ container.addChild(list);
554
+ container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0));
555
+ container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
556
+
557
+ return {
558
+ render: (width: number) => container.render(width),
559
+ invalidate: () => container.invalidate(),
560
+ handleInput: (data: string) => {
561
+ list.handleInput?.(data);
562
+ tui.requestRender?.();
563
+ }
564
+ };
565
+ });
566
+ }
567
+
568
+ async function openSettingsSectionMenu(ctx: any): Promise<SettingsSection | undefined> {
569
+ return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: SettingsSection | undefined) => void) => {
570
+ const container = new Container();
571
+ const items: SelectItem[] = [
572
+ { value: 'presentation', label: 'Presentation', description: 'Compact, preview, and verbose output modes' },
573
+ { value: 'backends', label: 'Backends', description: 'Search/fetch providers, URLs, and fallbacks' }
574
+ ];
575
+
576
+ container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
577
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent settings')), 1, 0));
578
+
579
+ const list = new SelectList(items, items.length, {
580
+ selectedPrefix: (text: string) => theme.fg('accent', text),
581
+ selectedText: (text: string) => theme.fg('accent', text),
582
+ description: (text: string) => theme.fg('muted', text),
583
+ scrollInfo: (text: string) => theme.fg('dim', text),
584
+ noMatch: (text: string) => theme.fg('warning', text)
585
+ });
586
+
587
+ list.onSelect = (item) => done(item.value as SettingsSection);
588
+ list.onCancel = () => done(undefined);
589
+ container.addChild(list);
590
+ container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0));
591
+ container.addChild(new DynamicBorder((text: string) => theme.fg('accent', text)));
592
+
593
+ return {
594
+ render: (width: number) => container.render(width),
595
+ invalidate: () => container.invalidate(),
596
+ handleInput: (data: string) => {
597
+ list.handleInput?.(data);
598
+ tui.requestRender?.();
599
+ }
600
+ };
601
+ });
602
+ }
603
+
604
+ async function openPresentationSettingsUi(
605
+ ctx: any,
606
+ loaded: Awaited<LoadedPresentationConfig>,
607
+ initialScope: PresentationScope
608
+ ): Promise<SettingsUiResult | undefined> {
609
+ return ctx.ui.custom((_tui: unknown, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
610
+ let state = createSettingsDraftState(loaded, initialScope);
611
+ let settingsList: SettingsList;
612
+
613
+ const container = new Container();
614
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · presentation')), 1, 1));
615
+ container.addChild(
616
+ new Text(
617
+ theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel'),
618
+ 1,
619
+ 2
620
+ )
621
+ );
622
+
623
+ const rebuildSettingsList = () => {
624
+ if (settingsList) {
625
+ container.removeChild(settingsList);
626
+ }
627
+
628
+ settingsList = new SettingsList(
629
+ buildPresentationSettingsItems(state.scope, state.config),
630
+ Math.min(PRESENTATION_TOOL_NAMES.length + 8, 18),
631
+ getSettingsListTheme(),
632
+ (id, newValue) => {
633
+ state = applySettingsValue(state, id, newValue);
634
+ rebuildSettingsList();
635
+ container.invalidate();
636
+ },
637
+ () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }),
638
+ { enableSearch: true }
639
+ );
640
+
641
+ container.addChild(settingsList);
642
+ };
643
+
644
+ rebuildSettingsList();
645
+
646
+ return {
647
+ render: (width: number) => container.render(width),
648
+ invalidate: () => container.invalidate(),
649
+ handleInput: (data: string) => {
650
+ const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
651
+
652
+ if (shortcut?.action === 'cancel') {
653
+ done({ action: 'cancel' });
654
+ return;
655
+ }
656
+
657
+ if (shortcut?.action === 'reset') {
658
+ done({ action: 'reset', scope: state.scope });
659
+ return;
660
+ }
661
+
662
+ if (shortcut?.action === 'save') {
663
+ done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
664
+ return;
665
+ }
666
+
667
+ settingsList.handleInput?.(data);
668
+ }
669
+ };
670
+ });
671
+ }
672
+
673
+ async function openBackendSettingsUi(
674
+ ctx: any,
675
+ loaded: Awaited<LoadedPresentationConfig>,
676
+ initialScope: PresentationScope
677
+ ): Promise<SettingsUiResult | undefined> {
678
+ return ctx.ui.custom((tui: any, theme: any, _kb: unknown, done: (value: SettingsUiResult) => void) => {
679
+ let state = createSettingsDraftState(loaded, initialScope);
680
+ let settingsList: SettingsList;
681
+
682
+ const container = new Container();
683
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent · backends')), 1, 1));
684
+ container.addChild(
685
+ new Text(
686
+ theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel · API keys stay in env vars'),
687
+ 1,
688
+ 2
689
+ )
690
+ );
691
+
692
+ const editUrl = async (id: string) => {
693
+ const isSearchUrl = id === 'backend:search:baseUrl';
694
+ const label = isSearchUrl ? 'SearXNG base URL' : 'Firecrawl base URL';
695
+ const currentValue = isSearchUrl ? state.backends.search.baseUrl : state.backends.fetch.baseUrl;
696
+ const entered = await ctx.ui.input(label, currentValue ?? (isSearchUrl ? 'http://localhost:8080' : 'http://localhost:3002'));
697
+ if (entered === undefined) return;
698
+
699
+ if (!entered.trim()) {
700
+ state = applySettingsValue(state, id, '');
701
+ rebuildSettingsList();
702
+ tui.requestRender?.();
703
+ return;
704
+ }
705
+
706
+ const validated = validateBackendUrl(entered);
707
+ if (!validated.ok) {
708
+ ctx.ui.notify(validated.message, 'warning');
709
+ return;
710
+ }
711
+
712
+ state = applySettingsValue(state, id, validated.value);
713
+ rebuildSettingsList();
714
+ tui.requestRender?.();
715
+ };
716
+
717
+ const rebuildSettingsList = () => {
718
+ if (settingsList) {
719
+ container.removeChild(settingsList);
720
+ }
721
+
722
+ settingsList = new SettingsList(
723
+ buildBackendSettingsItems(state.scope, state.backends),
724
+ 12,
725
+ getSettingsListTheme(),
726
+ (id, newValue) => {
727
+ if (id === 'backend:search:baseUrl' || id === 'backend:fetch:baseUrl') {
728
+ void editUrl(id);
729
+ return;
730
+ }
731
+
732
+ state = applySettingsValue(state, id, newValue);
733
+ rebuildSettingsList();
734
+ container.invalidate();
735
+ },
736
+ () => done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends }),
737
+ { enableSearch: true }
738
+ );
739
+
740
+ container.addChild(settingsList);
741
+ };
742
+
743
+ rebuildSettingsList();
744
+
745
+ return {
746
+ render: (width: number) => container.render(width),
747
+ invalidate: () => container.invalidate(),
748
+ handleInput: (data: string) => {
749
+ const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
750
+
751
+ if (shortcut?.action === 'cancel') {
752
+ done({ action: 'cancel' });
753
+ return;
754
+ }
755
+
756
+ if (shortcut?.action === 'reset') {
757
+ done({ action: 'reset', scope: state.scope });
758
+ return;
759
+ }
760
+
761
+ if (shortcut?.action === 'save') {
762
+ done({ action: 'save', scope: state.scope, config: state.config, backends: state.backends });
763
+ return;
764
+ }
765
+
766
+ settingsList.handleInput?.(data);
767
+ }
768
+ };
769
+ });
770
+ }
771
+
772
+ export function registerWebAgentConfigCommands(pi: ExtensionAPI, deps: CommandDeps = {}) {
773
+ const load = deps.load ?? (() => loadPresentationConfigLayers());
774
+ const save = deps.save ?? ((scope, config) => savePresentationConfigScope({}, scope, config));
775
+ const saveBackends = deps.saveBackends ?? ((scope, config) => saveBackendConfigScope({}, scope, config));
776
+ const reset = deps.reset ?? ((scope) => resetPresentationConfigScope({}, scope));
777
+ const resolveBrowser = deps.resolveBrowser ?? (() => resolveBrowserExecutable({}));
778
+ const runtime = deps.runtime ?? {
779
+ nodeVersion: process.version,
780
+ platform: process.platform,
781
+ arch: process.arch
782
+ };
783
+ const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
784
+ const checkBackends = deps.checkBackends ?? ((config: BackendConfig) => checkBackendHealth(config));
785
+ const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
786
+
787
+ pi.registerCommand('web-agent', {
788
+ description: 'Open settings or manage pi-web-agent presentation config',
789
+ handler: async (args, ctx) => {
790
+ let [action, maybeScope] = (args ?? '').trim().split(/\s+/).filter(Boolean);
791
+
792
+ if (!action) {
793
+ const selectedAction = await openActionMenu(ctx);
794
+ if (!selectedAction) return;
795
+
796
+ if (selectedAction === 'reset-project') {
797
+ action = 'reset';
798
+ maybeScope = 'project';
799
+ } else if (selectedAction === 'reset-global') {
800
+ action = 'reset';
801
+ maybeScope = 'global';
802
+ } else {
803
+ action = selectedAction;
804
+ }
805
+ }
806
+
807
+ if (action === 'doctor') {
808
+ const [typeboxOk, browser, loaded] = await Promise.all([checkTypebox(), resolveBrowser(), load()]);
809
+ const backendConfig = loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG;
810
+ const backendIssues = validateBackendConfig(backendConfig);
811
+ const backendHealth = await checkBackends(backendConfig);
812
+ const lines = [
813
+ 'pi-web-agent: loaded',
814
+ `runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
815
+ `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
816
+ formatBackendSummary(backendConfig),
817
+ backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
818
+ ...backendHealth
819
+ ];
820
+
821
+ if (browser.ok) {
822
+ lines.push(`browser: ${browser.browser} ${browser.executablePath}`);
823
+ } else {
824
+ lines.push(`browser: missing (${browser.error.code})`);
825
+ lines.push(browser.error.message);
826
+ lines.push('Install Chrome, Chromium, Edge, or Brave and run /web-agent doctor again.');
827
+ }
828
+
829
+ ctx.ui.notify(lines.join('\n'), 'info');
830
+ return;
831
+ }
832
+
833
+ if (action === 'show') {
834
+ const loaded = await load();
835
+ ctx.ui.notify(
836
+ [
837
+ formatConfigSummary(loaded.effectiveConfig),
838
+ formatBackendSummary(loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG),
839
+ `global: ${loaded.global.path}${loaded.global.exists ? '' : ' (missing)'}`,
840
+ `project: ${loaded.project.path}${loaded.project.exists ? '' : ' (missing)'}`
841
+ ].join('\n'),
842
+ 'info'
843
+ );
844
+ return;
845
+ }
846
+
847
+ if (action === 'changelog') {
848
+ const changelog = await getChangelog();
849
+ ctx.ui.notify(changelog ?? 'No pi-web-agent changelog entries found.', 'info');
850
+ return;
851
+ }
852
+
853
+ if (action === 'reset') {
854
+ const scope = parseScopeToken(maybeScope) ?? 'project';
855
+ await reset(scope);
856
+ ctx.ui.notify(`Reset ${scope} config`, 'info');
857
+ return;
858
+ }
859
+
860
+ if (action === 'mode') {
861
+ const [, first, second] = (args ?? '').trim().split(/\s+/).filter(Boolean);
862
+ const loaded = await load();
863
+ const scope: PresentationScope = 'project';
864
+ const baseConfig = getScopeDisplayConfig(loaded, scope);
865
+ const inheritedConfig = getInheritedConfigForScope(loaded, scope);
866
+
867
+ if (first && isModeOrInherit(first) && first !== 'inherit') {
868
+ await save(scope, collapsePresentationConfigToOverride({ ...baseConfig, defaultMode: first }, inheritedConfig));
869
+ ctx.ui.notify(`Saved project default mode = ${first}`, 'info');
870
+ return;
871
+ }
872
+
873
+ if (first && second && isToolName(first) && isModeOrInherit(second)) {
874
+ const nextTools = { ...baseConfig.tools };
875
+
876
+ if (second === 'inherit') {
877
+ delete nextTools[first];
878
+ } else {
879
+ nextTools[first] = { mode: second };
880
+ }
881
+
882
+ await save(
883
+ scope,
884
+ collapsePresentationConfigToOverride({ ...baseConfig, tools: nextTools }, inheritedConfig)
885
+ );
886
+ ctx.ui.notify(`Saved project ${first} = ${second}`, 'info');
887
+ return;
888
+ }
889
+
890
+ ctx.ui.notify(
891
+ 'Usage: /web-agent mode <compact|preview|verbose> or /web-agent mode <tool> <inherit|compact|preview|verbose>',
892
+ 'info'
893
+ );
894
+ return;
895
+ }
896
+
897
+ if (!action || action === 'settings') {
898
+ const loaded = await load();
899
+ const initialScope: PresentationScope = 'project';
900
+ const section = await openSettingsSectionMenu(ctx);
901
+ if (!section) return;
902
+
903
+ const result = section === 'presentation'
904
+ ? await openPresentationSettingsUi(ctx, loaded, initialScope)
905
+ : await openBackendSettingsUi(ctx, loaded, initialScope);
906
+
907
+ if (!result || result.action === 'cancel') {
908
+ return;
909
+ }
910
+
911
+ if (result.action === 'reset') {
912
+ await reset(result.scope);
913
+ ctx.ui.notify(`Reset ${result.scope} config`, 'info');
914
+ return;
915
+ }
916
+
917
+ if (section === 'presentation') {
918
+ await save(
919
+ result.scope,
920
+ collapsePresentationConfigToOverride(
921
+ result.config,
922
+ getInheritedConfigForScope(loaded, result.scope)
923
+ )
924
+ );
925
+ ctx.ui.notify(`Saved ${result.scope} presentation config`, 'info');
926
+ return;
927
+ }
928
+
929
+ await saveBackends(
930
+ result.scope,
931
+ collapseBackendConfigToOverride(
932
+ result.backends,
933
+ getInheritedBackendsForScope(loaded, result.scope)
934
+ )
935
+ );
936
+ ctx.ui.notify(`Saved ${result.scope} backend config`, 'info');
937
+ return;
938
+ }
939
+
940
+ ctx.ui.notify(
941
+ 'Use /web-agent, /web-agent show, /web-agent doctor, /web-agent changelog, /web-agent reset project, or /web-agent settings',
942
+ 'info'
943
+ );
944
+ }
945
+ });
946
+ }