@visual-z/xeno 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Custom endpoints and connected-provider management, reached from `/provider`.
3
+ *
4
+ * Flow (magpie's add form + OpenCode's /connect, see
5
+ * docs/tasks/feat-provider-connect.md): choose a preset or a custom endpoint
6
+ * → base URL and id (custom only) → API key (masked; `$VAR` keeps it in the
7
+ * environment) → list the provider's own models with that key → check the
8
+ * ones to enable → save → offer to switch.
9
+ *
10
+ * Every write goes through the files the rest of xeno already reads:
11
+ * `auth.json` for keys and `models.json` `providers.<id>` for endpoints and
12
+ * enabled models. After saving, the catalog is reloaded through the same
13
+ * entry `/reload` uses, so a connected provider behaves exactly like a
14
+ * hand-configured one.
15
+ */
16
+ import { modelKey } from "../core/session-position.ts";
17
+ import { loadCatalog } from "../runtime/catalog.ts";
18
+ import type { ModelSpec } from "../runtime/model.ts";
19
+ import { discoverModels, discoveryFamily, likelyChatModel, normalizeBaseUrl, type DiscoveredModel } from "../runtime/model-discovery.ts";
20
+ import { SOURCE_PROVIDERS } from "../runtime/model-data.ts";
21
+ import { applyConnect, applyDisconnect, catalogTwin } from "../runtime/provider-connect.ts";
22
+ import { CUSTOM_PRESETS, presetById, suggestProviderId, validProviderId, type ProviderPreset } from "../runtime/provider-presets.ts";
23
+ import { providerAccess } from "../runtime/provider-access.ts";
24
+ import { writeAuthFile } from "../store/auth-store.ts";
25
+ import { authPath } from "../store/config.ts";
26
+ import { modelsPath, readModelSources, readModelsText, writeModelsFile } from "../store/models.ts";
27
+ import { publishModelCatalog } from "./model-catalog.ts";
28
+ import type { App } from "./startup.ts";
29
+ import type { DialogHost } from "../tui/ui/dialog.ts";
30
+ import type { ListItem } from "../tui/ui/list-model.ts";
31
+ type SelectItem = ListItem;
32
+
33
+ /** Lists at or below this size are preselected whole; larger ones keep only known models. magpie uses 24. */
34
+ const PRESELECT_ALL = 24;
35
+
36
+ export interface ConnectHost {
37
+ app: App;
38
+ wizard: Pick<DialogHost, "push" | "reset" | "pop" | "top" | "depth">;
39
+ /** Unmount the wizard. */
40
+ close(): void;
41
+ switchTo(spec: ModelSpec): Promise<void>;
42
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
43
+ }
44
+
45
+ interface Draft {
46
+ preset: ProviderPreset;
47
+ id: string;
48
+ name: string;
49
+ baseUrl: string;
50
+ key: string;
51
+ /** `$VAR` / `{env:VAR}` / `!cmd`: stays in config, never stored. */
52
+ reference?: string;
53
+ previous?: readonly string[];
54
+ }
55
+
56
+ export function providerReady(app: App, provider: string): { ready: number; source?: string } {
57
+ const models = app.catalog.filter(model => model.provider === provider);
58
+ const resolved = models.map(model => app.credentialOf(model)).find(got => got.ok);
59
+ return { ready: resolved ? models.filter(model => app.credentialOf(model).ok).length : 0, ...(resolved?.ok ? { source: resolved.source } : {}) };
60
+ }
61
+
62
+ function providerName(app: App, id: string): string {
63
+ return SOURCE_PROVIDERS[id]?.name ?? (app.catalog.find(model => model.provider === id) ? connectedName(id) : id);
64
+ }
65
+
66
+ function connectedName(id: string): string {
67
+ return CUSTOM_PRESETS.find(preset => preset.id === id)?.label ?? id;
68
+ }
69
+
70
+ /**
71
+ * Entry points from `/provider`. API-key and subscription sign-in stay on the
72
+ * login picker itself (their masked editor and OAuth flows are fixed by
73
+ * tests); the wizard owns what login never did: custom endpoints and
74
+ * per-provider model choice.
75
+ */
76
+ export function startConnect(host: ConnectHost, entry: "custom" | "manage"): void {
77
+ const { app, wizard } = host;
78
+ if (entry === "custom") {
79
+ wizard.push({
80
+ kind: "list", keep: true,
81
+ title: "Which protocol does the endpoint speak?",
82
+ items: CUSTOM_PRESETS.map(preset => ({ value: preset.id, label: preset.label, description: preset.hint ?? "" })),
83
+ onPick: item => { const preset = presetById(item.value); if (preset) beginPreset(host, preset); },
84
+ });
85
+ return;
86
+ }
87
+ const connected = [...new Set(app.catalog.map(model => model.provider))].filter(id => providerReady(app, id).ready > 0).sort();
88
+ if (connected.length === 0) {
89
+ wizard.push({ kind: "message", title: "No provider is connected yet", lines: ["Choose a subscription, an API-key provider or a custom endpoint from /provider first."], onDone: () => host.close() });
90
+ return;
91
+ }
92
+ wizard.push({
93
+ kind: "list", keep: true,
94
+ title: `${connected.length} connected · choose one to manage`,
95
+ items: connected.map(id => {
96
+ const state = providerReady(app, id);
97
+ return { value: id, label: providerName(app, id), description: `${state.ready} model${state.ready === 1 ? "" : "s"} · ${state.source ?? "configured"}` };
98
+ }),
99
+ onPick: item => manage(host, item.value),
100
+ });
101
+ }
102
+
103
+ /** A provider configured by hand (or by an earlier /provider) that is not a preset. */
104
+ function customFromCatalog(app: App, id: string): ProviderPreset | undefined {
105
+ const model = app.catalog.find(candidate => candidate.provider === id);
106
+ if (!model) return undefined;
107
+ return { id, label: id, api: model.api, baseUrl: model.baseUrl ?? "", catalog: false, custom: true };
108
+ }
109
+
110
+ function manage(host: ConnectHost, id: string): void {
111
+ const { app, wizard } = host;
112
+ const preset = presetById(id) ?? customFromCatalog(app, id);
113
+ if (!preset) return;
114
+ const enabled = app.catalog.filter(model => model.provider === id).map(model => model.id);
115
+ wizard.push({
116
+ kind: "list", keep: true,
117
+ title: providerName(app, id),
118
+ items: [
119
+ { value: "models", label: "Choose models", description: "list the provider's models again and pick which appear in /model" },
120
+ { value: "key", label: "Replace API key", description: app.auth[id] ? `stored in ${authPath()}` : "currently from the environment or config" },
121
+ { value: "remove", label: "Disconnect", description: "remove the stored key and this provider's endpoint config" },
122
+ ],
123
+ onPick: async item => {
124
+ const sample = app.catalog.find(model => model.provider === id);
125
+ const baseUrl = sample?.baseUrl ?? preset.baseUrl;
126
+ const got = sample ? app.credentialOf(sample) : undefined;
127
+ const draft: Draft = { preset, id, name: preset.custom ? id : preset.label, baseUrl, key: got?.ok ? got.key : "", previous: enabled };
128
+ if (item.value === "models") { await fetchModels(host, draft); return; }
129
+ if (item.value === "key") { askKey(host, draft); return; }
130
+ await disconnect(host, id);
131
+ },
132
+ });
133
+ }
134
+
135
+ function beginPreset(host: ConnectHost, preset: ProviderPreset): void {
136
+ const { app, wizard } = host;
137
+ const draft: Draft = { preset, id: preset.id, name: preset.label, baseUrl: preset.baseUrl, key: "" };
138
+ if (!preset.custom) { askKey(host, draft); return; }
139
+ wizard.push({
140
+ kind: "form", keep: true,
141
+ title: `${preset.label} · base URL`,
142
+ label: "URL",
143
+ value: preset.baseUrl,
144
+ hint: preset.api === "anthropic-messages" ? "the root, e.g. https://gateway.example.com/anthropic · Enter continue · Esc back"
145
+ : preset.api === "google-generative-ai" ? "e.g. https://generativelanguage.googleapis.com/v1beta · Enter continue · Esc back"
146
+ : "usually ends in /v1 · a pasted …/chat/completions is trimmed · Enter continue · Esc back",
147
+ validate: value => /^https?:\/\/[^\s/]+/.test(value) ? undefined : "Enter an http(s) URL",
148
+ onSubmit: value => {
149
+ draft.baseUrl = normalizeBaseUrl(value);
150
+ const taken = new Set(app.catalog.map(model => model.provider).filter(id => !(id in app.auth) && SOURCE_PROVIDERS[id] !== undefined));
151
+ const suggested = preset.keyless ? preset.id : suggestProviderId(draft.baseUrl, taken);
152
+ wizard.push({
153
+ kind: "form", keep: true,
154
+ title: `${preset.label} · name`,
155
+ label: "Provider ID",
156
+ value: suggested,
157
+ hint: "shown in /model as <id>/<model>; lowercase letters, digits, - and _",
158
+ validate: id => !validProviderId(id) ? "Use lowercase letters, digits, - and _ (start with a letter or digit)"
159
+ : SOURCE_PROVIDERS[id] !== undefined ? `"${id}" is a built-in provider; pick another id or choose it from the list` : undefined,
160
+ onSubmit: id => {
161
+ draft.id = id;
162
+ draft.name = id;
163
+ if (preset.keyless) { void fetchModels(host, draft); return; }
164
+ askKey(host, draft);
165
+ },
166
+ });
167
+ },
168
+ });
169
+ }
170
+
171
+ function askKey(host: ConnectHost, draft: Draft): void {
172
+ const { app, wizard } = host;
173
+ const sample = app.catalog.find(model => model.provider === draft.id);
174
+ const existing = sample ? app.credentialOf(sample) : undefined;
175
+ const reuse = existing?.ok ? existing.source : undefined;
176
+ wizard.push({
177
+ kind: "form", keep: true,
178
+ title: `${draft.name} · API key`,
179
+ label: "Key",
180
+ secret: true,
181
+ hint: [reuse ? `empty keeps the current key (${reuse})` : draft.preset.keyUrl ? `get one at ${draft.preset.keyUrl}` : "paste the key",
182
+ "$VAR reads the environment", "Enter continue · Esc back"].join(" · "),
183
+ validate: value => value.length === 0 && !reuse ? "Paste a key, or $VAR to read one from the environment" : undefined,
184
+ onSubmit: async value => {
185
+ if (value.length === 0 && existing?.ok) draft.key = existing.key;
186
+ else if (/^(\$|\{env:|!)/.test(value)) {
187
+ draft.reference = value;
188
+ const name = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(value)?.[1] ?? /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value)?.[1];
189
+ draft.key = name ? process.env[name] ?? "" : "";
190
+ } else draft.key = value;
191
+ await fetchModels(host, draft);
192
+ },
193
+ });
194
+ }
195
+
196
+ async function fetchModels(host: ConnectHost, draft: Draft): Promise<void> {
197
+ const { app, wizard } = host;
198
+ const family = discoveryFamily(draft.preset.api);
199
+ const known = app.catalog.filter(model => model.provider === draft.id);
200
+ const builtin = loadCatalog({ userText: null }).models.filter(model => model.provider === draft.id);
201
+ const catalogModels = [...new Map([...builtin, ...known].map(model => [model.id, model])).values()];
202
+ if (!family) { chooseModels(host, draft, catalogModels.map(toDiscovered), undefined); return; }
203
+ const abort = new AbortController();
204
+ wizard.push({ kind: "busy", title: "models", message: `Listing models from ${draft.baseUrl} …`, onCancel: () => abort.abort() });
205
+ const headers = Object.fromEntries(Object.entries(providerAccess(known[0] ?? ({} as ModelSpec)).headers ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string" && !entry[1].includes("$")));
206
+ const result = await discoverModels({ family, baseUrl: draft.baseUrl, key: draft.key, headers, fetch: host.fetch ?? ((input, init) => globalThis.fetch(input, init)), signal: abort.signal });
207
+ if (abort.signal.aborted || wizard.top?.kind !== "busy") return;
208
+ if (result.ok) {
209
+ const live = result.models;
210
+ // Catalog metadata wins for display; the live list decides availability.
211
+ const merged = live.map(model => {
212
+ const twin = catalogModels.find(candidate => candidate.id === model.id);
213
+ return twin ? { ...toDiscovered(twin), ...model, name: model.name ?? twin.name } : model;
214
+ });
215
+ chooseModels(host, draft, merged, `${live.length} models from ${result.url}`, true);
216
+ return;
217
+ }
218
+ const options: SelectItem[] = [
219
+ { value: "retry", label: "Retry", description: result.reason },
220
+ { value: "key", label: "Change the API key" },
221
+ ...(draft.preset.custom ? [{ value: "url", label: "Change the base URL", description: draft.baseUrl }] : []),
222
+ ...(catalogModels.length ? [{ value: "catalog", label: `Use the ${catalogModels.length} built-in models`, description: "skip the live list" }] : []),
223
+ { value: "manual", label: "Type model IDs", description: "for endpoints without a /models listing" },
224
+ ];
225
+ wizard.push({
226
+ kind: "list", keep: true,
227
+ title: `Could not list models: ${result.reason}`,
228
+ items: options,
229
+ onPick: async item => {
230
+ if (item.value === "retry") { wizard.pop(); await fetchModels(host, draft); }
231
+ else if (item.value === "key") { wizard.pop(); wizard.pop(); askKey(host, draft); }
232
+ else if (item.value === "url") { while (wizard.depth > 1 && wizard.top?.title !== `${draft.preset.label} · base URL`) wizard.pop(); }
233
+ else if (item.value === "catalog") chooseModels(host, draft, catalogModels.map(toDiscovered), "built-in catalog");
234
+ else wizard.push({
235
+ kind: "form", keep: true, title: "Model IDs", label: "IDs", hint: "comma or space separated, exactly as the provider names them · Enter save",
236
+ validate: value => value.split(/[\s,]+/).filter(Boolean).length ? undefined : "Enter at least one model id",
237
+ onSubmit: async value => { await save(host, draft, value.split(/[\s,]+/).filter(Boolean).map(id => ({ id }))); },
238
+ });
239
+ },
240
+ }, true);
241
+ }
242
+
243
+ function toDiscovered(model: ModelSpec): DiscoveredModel {
244
+ return { id: model.id, ...(model.name !== model.id ? { name: model.name } : {}),
245
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.maxTokens ? { maxTokens: model.maxTokens } : {}) };
246
+ }
247
+
248
+ function chooseModels(host: ConnectHost, draft: Draft, models: readonly DiscoveredModel[], source: string | undefined, replace = false): void {
249
+ const { app, wizard } = host;
250
+ const chat = models.filter(model => likelyChatModel(model.id));
251
+ const known = new Set(app.catalog.filter(model => model.provider === draft.id).map(model => model.id));
252
+ const preselect = (model: DiscoveredModel): boolean => {
253
+ if (draft.previous) return draft.previous.includes(model.id);
254
+ if (!likelyChatModel(model.id)) return false;
255
+ if (chat.length <= PRESELECT_ALL) return true;
256
+ return known.has(model.id) || catalogTwin(model.id, app.catalog) !== undefined && draft.preset.custom === false;
257
+ };
258
+ const ordered = [...chat, ...models.filter(model => !likelyChatModel(model.id))];
259
+ const items: ListItem[] = ordered.map(model => ({
260
+ value: model.id,
261
+ label: model.id,
262
+ description: [model.name && model.name !== model.id ? model.name : "", model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k ctx` : "", likelyChatModel(model.id) ? "" : "not a chat model?"].filter(Boolean).join(" · "),
263
+ }));
264
+ wizard.push({
265
+ kind: "list", multi: true, keep: true,
266
+ title: `${draft.name} · models to enable${source ? ` (${source})` : ""}`,
267
+ items, checked: ordered.filter(preselect).map(model => model.id),
268
+ hint: "space toggle · ctrl+a all · enter save · esc back",
269
+ onSubmitChecked: async values => {
270
+ if (!values.length) { fail(host, "Select at least one model (space toggles, ctrl+a selects all)"); return; }
271
+ await save(host, draft, ordered.filter(model => values.includes(model.id)));
272
+ },
273
+ }, replace);
274
+ }
275
+
276
+ async function save(host: ConnectHost, draft: Draft, chosen: readonly DiscoveredModel[]): Promise<void> {
277
+ const { app, wizard } = host;
278
+ const read = await readModelsText();
279
+ let json: unknown = {};
280
+ if (read.error) { fail(host, `${read.error}; nothing was changed`); return; }
281
+ if (read.text !== null && read.text.trim().length) {
282
+ try { json = JSON.parse(read.text); }
283
+ catch { fail(host, `${modelsPath()} is not valid JSON; fix it first — nothing was changed`); return; }
284
+ }
285
+ const catalogDefault = SOURCE_PROVIDERS[draft.id]?.baseUrl;
286
+ const keyless = draft.preset.keyless === true;
287
+ const next = applyConnect(json, {
288
+ id: draft.id, name: draft.name, api: draft.preset.api, baseUrl: draft.baseUrl, catalog: !draft.preset.custom,
289
+ ...(catalogDefault ? { catalogBaseUrl: catalogDefault } : {}), keyless,
290
+ ...(draft.reference ? { keyReference: draft.reference } : {}), chosen,
291
+ }, loadCatalog({ userText: null }).models);
292
+ if (!keyless && !draft.reference && draft.key && (app.auth[draft.id]?.type !== "api_key" || app.auth[draft.id]?.type === "api_key" && (app.auth[draft.id] as { key: string }).key !== draft.key)) {
293
+ const auth = { ...app.auth, [draft.id]: { type: "api_key" as const, key: draft.key } };
294
+ await writeAuthFile(auth);
295
+ app.auth[draft.id] = auth[draft.id];
296
+ }
297
+ await writeModelsFile(next);
298
+ const loaded = loadCatalog(await readModelSources());
299
+ await publishModelCatalog(app, loaded.models);
300
+ const ready = app.catalog.filter(model => model.provider === draft.id && app.credentialOf(model).ok);
301
+ const warnings = loaded.warnings.filter(warning => warning.includes(draft.id));
302
+ if (ready.length === 0) {
303
+ fail(host, warnings[0] ?? "saved, but no model of this provider resolves a credential yet — check the key");
304
+ return;
305
+ }
306
+ app.io.say(app.io.palette.green(` connected ${draft.name}`) + app.io.palette.dim(` ${ready.length} model${ready.length === 1 ? "" : "s"} · ${modelsPath()}`));
307
+ wizard.reset({
308
+ kind: "list",
309
+ title: `Connected ${draft.name} · ${ready.length} model${ready.length === 1 ? "" : "s"} ready. Switch now?`,
310
+ items: [
311
+ ...ready.map(model => ({ value: modelKey(model), label: model.id, description: model.name !== model.id ? model.name : "" })),
312
+ { value: "stay", label: "Keep the current model", description: modelKey(app.providerConfig.spec) },
313
+ ],
314
+ onPick: async item => {
315
+ host.close();
316
+ const spec = app.catalog.find(model => modelKey(model) === item.value);
317
+ if (spec) await host.switchTo(spec);
318
+ },
319
+ });
320
+ }
321
+
322
+ async function disconnect(host: ConnectHost, id: string): Promise<void> {
323
+ const { app } = host;
324
+ const read = await readModelsText();
325
+ if (read.text !== null && read.text.trim().length) {
326
+ let json: unknown;
327
+ try { json = JSON.parse(read.text); } catch { fail(host, `${modelsPath()} is not valid JSON; nothing was changed`); return; }
328
+ await writeModelsFile(applyDisconnect(json, id));
329
+ }
330
+ if (app.auth[id] !== undefined) {
331
+ const auth = { ...app.auth };
332
+ delete auth[id];
333
+ await writeAuthFile(auth);
334
+ delete app.auth[id];
335
+ }
336
+ const loaded = loadCatalog(await readModelSources());
337
+ if (app.providerConfig.spec.provider === id) app.io.say(app.io.palette.yellow(" the current model belonged to this provider; choose another with /model"));
338
+ await publishModelCatalog(app, loaded.models);
339
+ const left = providerReady(app, id).ready;
340
+ app.io.say(app.io.palette.green(` disconnected ${id}`) + (left ? app.io.palette.dim(` (${left} models still resolve a key from the environment)`) : ""));
341
+ host.close();
342
+ }
343
+
344
+ function fail(host: ConnectHost, reason: string): void {
345
+ host.wizard.push({ kind: "message", title: "Not saved", lines: [reason] });
346
+ }
@@ -10,9 +10,8 @@
10
10
 
11
11
  import { assertNever } from "../core/exhaustive.ts";
12
12
  import type { LoopEvent } from "../runtime/loop.ts";
13
- import { renderMarkdown } from "../tui/markdown.ts";
14
13
  import { ANSI as ANSI_STYLE, IDENTITY, type Style } from "../tui/style.ts";
15
- import { renderToolView, type ToolViewEvent } from "../tui/toolview.ts";
14
+ import { toolCard, toolDetail } from "../tui/ui/tool-card.ts";
16
15
 
17
16
  export interface Palette {
18
17
  dim: (s: string) => string;
@@ -123,24 +122,6 @@ export interface FormatFlags {
123
122
  readonly thinkingExpanded?: boolean;
124
123
  }
125
124
 
126
- export function toToolView(e: LoopEvent): ToolViewEvent | null {
127
- switch (e.kind) {
128
- case "thinking":
129
- return { kind: "thinking", text: e.text ?? "" };
130
- case "tool_call":
131
- return { kind: "tool_call", tool: e.tool ?? { name: "", input: {} } };
132
- case "tool_result":
133
- return {
134
- kind: "tool_result",
135
- text: e.text ?? "",
136
- ok: e.ok !== false,
137
- ...(e.tool !== undefined ? { tool: e.tool } : {}),
138
- };
139
- default:
140
- return null;
141
- }
142
- }
143
-
144
125
  export function formatEvent(
145
126
  e: LoopEvent,
146
127
  C: Palette,
@@ -150,34 +131,28 @@ export function formatEvent(
150
131
  ): string[] {
151
132
  const md = markdownStyle(C, style);
152
133
  switch (e.kind) {
153
- case "thinking":
154
- return renderToolView(
155
- { kind: "thinking", text: e.text ?? "" },
156
- flags.thinkingExpanded === true,
157
- width,
158
- md,
159
- );
134
+ case "thinking": {
135
+ // Print mode sends this to stderr, so 2>&1 can still read the reasoning in full.
136
+ const text = (e.text ?? "").trim();
137
+ void flags;
138
+ return text ? text.split("\n").map(line => C.dim(` ${line}`)) : [];
139
+ }
160
140
  case "assistant_text":
161
- return renderMarkdown(e.text ?? "", width, md);
162
- case "tool_call":
163
- return renderToolView(
164
- { kind: "tool_call", tool: e.tool ?? { name: "", input: {} } },
165
- flags.toolsExpanded === true,
166
- width,
167
- md,
168
- );
169
- case "tool_result":
170
- return renderToolView(
171
- {
172
- kind: "tool_result",
173
- text: e.text ?? "",
174
- ok: e.ok !== false,
175
- ...(e.tool !== undefined ? { tool: e.tool } : {}),
176
- },
177
- flags.toolsExpanded === true,
178
- width,
179
- md,
180
- );
141
+ // Markdown stays source text in a pipe: scripts read it, and terminals render the TUI.
142
+ return (e.text ?? "").split("\n");
143
+ case "tool_call": {
144
+ const tool = e.tool ?? { name: "", input: {} };
145
+ const card = toolCard({ name: tool.name, input: tool.input, result: null });
146
+ return [C.cyan(` → ${tool.name}${card.summary ? ` ${card.summary}` : ""}`)];
147
+ }
148
+ case "tool_result": {
149
+ const tool = e.tool ?? { name: "", input: {} };
150
+ const call = { name: tool.name, input: tool.input, result: { text: e.text ?? "", ok: e.ok !== false } };
151
+ const card = toolCard(call);
152
+ const head = ` ${e.ok === false ? "err" : "ok "}${card.evidence ? ` ${card.evidence}` : ""}`;
153
+ const body = flags.toolsExpanded === true ? toolDetail({ ...call, input: tool.name === "bash" ? {} : tool.input }, width - 6, md).map(line => ` ${line}`) : [];
154
+ return [e.ok === false ? C.red(head) : C.dim(head), ...body];
155
+ }
181
156
  case "usage": {
182
157
  const u = e.usage!;
183
158
  const line =
@@ -1,18 +1,21 @@
1
- import { loadSessionResources, visibleSchemas, type App } from "./startup.ts";
1
+ import { loadSessionResources, sessionToolCatalog, type App } from "./startup.ts";
2
2
  import { loadCatalog } from "../runtime/catalog.ts";
3
3
  import { readModelSources } from "../store/models.ts";
4
4
  import { publishModelCatalog } from "./model-catalog.ts";
5
5
 
6
6
  /** Stage local resources, persist their checkpoint, then publish their UI/runtime views together. */
7
7
  export async function reloadAppResources(app: App): Promise<boolean> {
8
+ if (app.extensions && !await app.extensions.releaseIntegrations()) throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before reloading; no replacement was activated.");
8
9
  const resources = await loadSessionResources(app.args, app.workspace, app.io, app.projectTrusted);
9
10
  const catalog = loadCatalog(await readModelSources());
10
11
  for (const warning of catalog.warnings) app.io.say(app.io.palette.yellow(warning));
11
- const cut = await app.session.reloadResources({ system: resources.system, tools: visibleSchemas(app.args, resources.extensions) });
12
+ const tools = sessionToolCatalog(app.args, resources.extensions, app.session.config.frozen.tools);
13
+ const cut = await app.session.reloadResources({ system: resources.system, tools: tools.schemas }, [...resources.slash.skills.values()]);
12
14
  const modelCut = await publishModelCatalog(app, catalog.models, cut);
13
15
  await app.extensions?.dispose();
14
16
  app.extensions = resources.extensions;
15
17
  app.session.config.tools.extensions = resources.extensions.definitions();
18
+ app.session.config.tools.deferredTools = tools.deferred;
16
19
  app.session.config.tools.extensionHooks = resources.extensions.toolHooks();
17
20
  app.loadedContext = resources.loadedContext;
18
21
  app.slash = resources.slash;