@visual-z/xeno 0.2.1 → 0.3.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 (258) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +51 -13
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/commands.ts +38 -5
  8. package/src/apps/completions.ts +73 -19
  9. package/src/apps/image-draft.ts +46 -0
  10. package/src/apps/project-trust.ts +2 -42
  11. package/src/apps/provider-connect.ts +346 -0
  12. package/src/apps/render.ts +22 -47
  13. package/src/apps/resources.ts +5 -2
  14. package/src/apps/shell.ts +298 -1526
  15. package/src/apps/startup-input.ts +45 -9
  16. package/src/apps/startup.ts +128 -86
  17. package/src/apps/ui/app.ts +1383 -0
  18. package/src/apps/ui/extension-host.ts +194 -0
  19. package/src/apps/ui/image-cache.ts +78 -0
  20. package/src/apps/ui/palette.ts +88 -0
  21. package/src/apps/ui/project.ts +127 -0
  22. package/src/apps/ui/standalone.ts +146 -0
  23. package/src/apps/ui/status-model.ts +68 -0
  24. package/src/apps/ui/surface.ts +84 -0
  25. package/src/apps/ui/theme.ts +86 -0
  26. package/src/core/acceptance.ts +107 -0
  27. package/src/core/action-defaults.ts +47 -295
  28. package/src/core/benchmark.ts +2 -2
  29. package/src/core/branch-receipt.ts +24 -0
  30. package/src/core/checkpoints.ts +64 -0
  31. package/src/core/churn.ts +2 -0
  32. package/src/core/command-artifacts.ts +94 -0
  33. package/src/core/command-contract.ts +35 -0
  34. package/src/core/completion-review.ts +21 -5
  35. package/src/core/context-detail.ts +37 -0
  36. package/src/core/context-policy.ts +74 -1
  37. package/src/core/continuation.ts +17 -2
  38. package/src/core/derive.ts +29 -3
  39. package/src/core/draft-images.ts +56 -0
  40. package/src/core/environment.ts +145 -0
  41. package/src/core/evaluation-coverage.ts +74 -0
  42. package/src/core/event-validation.ts +80 -5
  43. package/src/core/events.ts +97 -3
  44. package/src/core/evidence-links.ts +7 -0
  45. package/src/core/evidence.ts +13 -3
  46. package/src/core/execution.ts +8 -2
  47. package/src/core/experiment.ts +133 -0
  48. package/src/core/export.ts +9 -0
  49. package/src/core/failure-recovery.ts +173 -0
  50. package/src/core/file-precondition.ts +72 -0
  51. package/src/core/handoff.ts +65 -4
  52. package/src/core/history-query.ts +38 -2
  53. package/src/core/history.ts +38 -1
  54. package/src/core/integration-health.ts +34 -0
  55. package/src/core/keymap.ts +8 -20
  56. package/src/core/model-picker.ts +64 -0
  57. package/src/core/native-eval.ts +25 -0
  58. package/src/core/note-guidance.ts +34 -0
  59. package/src/core/observation-view.ts +49 -8
  60. package/src/core/permissions.ts +24 -2
  61. package/src/core/process-receipt.ts +105 -0
  62. package/src/core/process-state.ts +37 -0
  63. package/src/core/project.ts +45 -18
  64. package/src/core/prompt-policy.ts +37 -0
  65. package/src/core/query-eval-contract.ts +14 -0
  66. package/src/core/read-observation.ts +82 -0
  67. package/src/core/recovery-corpus.ts +17 -0
  68. package/src/core/recovery-evaluation.ts +60 -0
  69. package/src/core/recovery-sources.ts +74 -0
  70. package/src/core/runtime-target.ts +94 -0
  71. package/src/core/search-observation.ts +97 -0
  72. package/src/core/session-tree.ts +147 -0
  73. package/src/core/settings.ts +26 -10
  74. package/src/core/skills.ts +44 -0
  75. package/src/core/sql-query.ts +211 -0
  76. package/src/core/theme.ts +36 -58
  77. package/src/core/tool-contract.ts +51 -2
  78. package/src/core/tool-discovery.ts +87 -0
  79. package/src/core/tool-failure.ts +60 -0
  80. package/src/core/tool-mode-protocol.ts +25 -0
  81. package/src/core/transcript.ts +19 -4
  82. package/src/core/trial-lifecycle.ts +14 -0
  83. package/src/core/tui-mode.ts +29 -0
  84. package/src/core/update-check.ts +97 -0
  85. package/src/core/version.ts +55 -0
  86. package/src/core/work-state.ts +49 -3
  87. package/src/core/working-set.ts +91 -0
  88. package/src/runtime/auth.ts +2 -2
  89. package/src/runtime/branch-jobs.ts +36 -0
  90. package/src/runtime/clipboard.ts +15 -4
  91. package/src/runtime/command-artifacts.ts +64 -0
  92. package/src/runtime/environment-tools.ts +60 -0
  93. package/src/runtime/evidence-notes.ts +4 -2
  94. package/src/runtime/exec-sessions.ts +206 -61
  95. package/src/runtime/execution-control.ts +1 -0
  96. package/src/runtime/extension-ui.ts +22 -30
  97. package/src/runtime/extensions.ts +95 -16
  98. package/src/runtime/harness-comparison.ts +27 -7
  99. package/src/runtime/harness-eval.ts +460 -0
  100. package/src/runtime/history-cache.ts +57 -0
  101. package/src/runtime/history-tools.ts +62 -12
  102. package/src/runtime/integrations.ts +210 -0
  103. package/src/runtime/loop.ts +1021 -268
  104. package/src/runtime/model-discovery.ts +208 -0
  105. package/src/runtime/native-eval-fixtures.ts +33 -0
  106. package/src/runtime/native-eval.ts +143 -0
  107. package/src/runtime/oauth.ts +1 -1
  108. package/src/runtime/provider-connect.ts +109 -0
  109. package/src/runtime/provider-presets.ts +87 -0
  110. package/src/runtime/query-eval-verifier.ts +28 -0
  111. package/src/runtime/recovery-coverage.ts +71 -0
  112. package/src/runtime/recovery-evidence.ts +76 -0
  113. package/src/runtime/recovery-fixtures.ts +116 -0
  114. package/src/runtime/runtime-target.ts +149 -0
  115. package/src/runtime/tool-broker.ts +137 -0
  116. package/src/runtime/tool-discovery.ts +57 -0
  117. package/src/runtime/tool-mode-eval.ts +146 -0
  118. package/src/runtime/tool-queue.ts +77 -0
  119. package/src/runtime/tools.ts +628 -183
  120. package/src/runtime/trial-control.ts +45 -0
  121. package/src/runtime/update-check.ts +47 -0
  122. package/src/runtime/window-trial.ts +2 -1
  123. package/src/runtime/working-notes.ts +33 -3
  124. package/src/runtime/workspace-lifecycle.ts +97 -0
  125. package/src/store/acceptance.ts +15 -0
  126. package/src/store/checkpoint-history.ts +43 -0
  127. package/src/store/command-artifacts.ts +96 -0
  128. package/src/store/command.ts +65 -37
  129. package/src/store/file-checkpoints.ts +104 -0
  130. package/src/store/file-index.ts +13 -1
  131. package/src/store/git-checkpoints.ts +67 -0
  132. package/src/store/observations.ts +61 -20
  133. package/src/store/process-receipts.ts +34 -0
  134. package/src/store/query-source.ts +13 -8
  135. package/src/store/recovery.ts +2 -3
  136. package/src/store/replay.ts +16 -4
  137. package/src/store/runtime-target.ts +64 -0
  138. package/src/store/search.ts +171 -132
  139. package/src/store/session-archive.ts +4 -0
  140. package/src/store/source-cancellation.ts +6 -0
  141. package/src/store/update-cache.ts +36 -0
  142. package/src/store/workspace-observation.ts +113 -0
  143. package/src/tui/ansi-spans.ts +126 -0
  144. package/src/tui/style.ts +19 -16
  145. package/src/tui/ui/atomic-draft.ts +121 -0
  146. package/src/tui/ui/canvas.ts +96 -0
  147. package/src/tui/ui/colors.ts +70 -0
  148. package/src/tui/ui/composer.ts +199 -0
  149. package/src/tui/ui/dialog.ts +379 -0
  150. package/src/tui/ui/draft.ts +23 -0
  151. package/src/tui/ui/edge.ts +186 -0
  152. package/src/tui/ui/image-size.ts +40 -0
  153. package/src/tui/ui/ink.ts +210 -0
  154. package/src/tui/ui/items.ts +40 -0
  155. package/src/tui/ui/keys.ts +40 -0
  156. package/src/tui/ui/layout.ts +74 -0
  157. package/src/tui/ui/list-model.ts +86 -0
  158. package/src/tui/ui/list-view.ts +172 -0
  159. package/src/tui/ui/mascot.ts +67 -0
  160. package/src/tui/ui/messages.ts +518 -0
  161. package/src/tui/ui/motion.ts +28 -0
  162. package/src/tui/ui/paths.ts +10 -0
  163. package/src/tui/ui/pictures.ts +78 -0
  164. package/src/tui/ui/screen.ts +421 -0
  165. package/src/tui/ui/status.ts +271 -0
  166. package/src/tui/ui/styled.ts +85 -0
  167. package/src/tui/ui/testing.ts +5 -0
  168. package/src/tui/ui/tool-card.ts +150 -0
  169. package/src/apps/extension-ui.ts +0 -330
  170. package/src/apps/resource-browser.ts +0 -75
  171. package/src/apps/session-browser.ts +0 -113
  172. package/src/apps/session-picker.ts +0 -55
  173. package/src/tui/ansi.ts +0 -67
  174. package/src/tui/autocomplete.ts +0 -29
  175. package/src/tui/chat-viewport.ts +0 -47
  176. package/src/tui/complete.ts +0 -87
  177. package/src/tui/editor.ts +0 -940
  178. package/src/tui/host.ts +0 -264
  179. package/src/tui/input.ts +0 -75
  180. package/src/tui/keys.ts +0 -378
  181. package/src/tui/markdown.ts +0 -17
  182. package/src/tui/render.ts +0 -153
  183. package/src/tui/select.ts +0 -258
  184. package/src/tui/settings.ts +0 -44
  185. package/src/tui/statusline.ts +0 -162
  186. package/src/tui/system-theme.ts +0 -700
  187. package/src/tui/terminal.ts +0 -30
  188. package/src/tui/text.ts +0 -52
  189. package/src/tui/toolview.ts +0 -386
  190. package/src/tui/transcript.ts +0 -123
  191. package/src/tui/tree.ts +0 -471
  192. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  193. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  194. package/src/vendor/pi-tui/VENDOR.md +0 -25
  195. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  196. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  197. package/src/vendor/pi-tui/colors.ts +0 -367
  198. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  199. package/src/vendor/pi-tui/components/box.ts +0 -166
  200. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  201. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  202. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  203. package/src/vendor/pi-tui/components/image.ts +0 -127
  204. package/src/vendor/pi-tui/components/input.ts +0 -494
  205. package/src/vendor/pi-tui/components/loader.ts +0 -101
  206. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  207. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  208. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  209. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  210. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  211. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  212. package/src/vendor/pi-tui/components/stack.ts +0 -154
  213. package/src/vendor/pi-tui/components/text.ts +0 -107
  214. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  215. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  216. package/src/vendor/pi-tui/editor-component.ts +0 -74
  217. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  218. package/src/vendor/pi-tui/index.ts +0 -186
  219. package/src/vendor/pi-tui/keybindings.ts +0 -320
  220. package/src/vendor/pi-tui/keys.ts +0 -1401
  221. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  222. package/src/vendor/pi-tui/latex.ts +0 -1506
  223. package/src/vendor/pi-tui/layout-node.ts +0 -51
  224. package/src/vendor/pi-tui/layout.ts +0 -449
  225. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  226. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  227. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  228. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  229. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  232. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  233. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  234. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  237. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  238. package/src/vendor/pi-tui/native/napi.h +0 -91
  239. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  240. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  241. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  244. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  245. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  246. package/src/vendor/pi-tui/native-platform.ts +0 -65
  247. package/src/vendor/pi-tui/oklab.ts +0 -233
  248. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  249. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  250. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  251. package/src/vendor/pi-tui/terminal.ts +0 -554
  252. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  253. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  254. package/src/vendor/pi-tui/tui.ts +0 -1493
  255. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  256. package/src/vendor/pi-tui/utils.ts +0 -1385
  257. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  258. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,46 @@
1
+ import { draftImageInput, draftImageSpans, focusedDraftImage, imageMarker } from "../core/draft-images.ts";
2
+ import type { ImageRef } from "../core/images.ts";
3
+
4
+ /** D19: the renderer owns text and undo; the app only binds tokens to D8 blob identities. */
5
+ export interface ImageDraftEditor {
6
+ readonly text: string;
7
+ readonly cursor: number;
8
+ setAtoms(spans: (text: string) => readonly { start: number; end: number }[]): void;
9
+ insert(text: string): void;
10
+ removeAtom(label: string): void;
11
+ resetDraft(project: (text: string) => string): void;
12
+ }
13
+
14
+ export class ImageDraft {
15
+ private readonly editor: ImageDraftEditor;
16
+ private readonly registry = new Map<number, ImageRef>();
17
+ // Never reuse an ID: undo, kill/yank and local history can restore an earlier tag.
18
+ private nextId = 1;
19
+
20
+ constructor(editor: ImageDraftEditor) {
21
+ this.editor = editor;
22
+ editor.setAtoms(text => draftImageSpans(text, this.registry));
23
+ }
24
+ get images(): ImageRef[] { return this.input(this.editor.text).images; }
25
+ get focusedImage() { return focusedDraftImage(this.editor.text, this.editor.cursor, this.registry); }
26
+ input(text: string) { return draftImageInput(text, this.registry); }
27
+
28
+ insertImages(images: readonly ImageRef[]): void {
29
+ let labels = "";
30
+ for (const image of images) {
31
+ while (this.editor.text.includes(imageMarker(this.nextId))) this.nextId++;
32
+ const id = this.nextId++;
33
+ this.registry.set(id, image); labels += imageMarker(id);
34
+ }
35
+ if (labels) this.editor.insert(labels);
36
+ }
37
+ removeImage(index: number): void {
38
+ const ids = [...new Set(draftImageSpans(this.editor.text, this.registry).map(span => span.id))];
39
+ const id = ids[index];
40
+ if (id !== undefined) this.editor.removeAtom(imageMarker(id));
41
+ }
42
+ reset(): void {
43
+ this.editor.resetDraft(text => this.input(text).text);
44
+ this.registry.clear();
45
+ }
46
+ }
@@ -1,49 +1,9 @@
1
+ /** Project trust decisions; the picker itself is apps/ui/standalone.ts (startup) and apps/ui/app.ts (/trust). */
1
2
  import { dirname } from "node:path";
2
- import { SelectList } from "../tui/select.ts";
3
- import { createInteractiveTui } from "../tui/host.ts";
4
- import { IDENTITY } from "../tui/style.ts";
5
- import { installBindings } from "../tui/input.ts";
6
- import { loadUiResources } from "../store/resources.ts";
7
- import type { Style } from "../tui/style.ts";
8
- import { projectTrust, saveTrust } from "../store/trust.ts";
9
- import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
3
+ import { saveTrust } from "../store/trust.ts";
10
4
 
11
5
  export type TrustChoice = "once" | "here" | "parent" | "never";
12
6
  export async function applyTrustChoice(workspace: string, choice: TrustChoice): Promise<boolean> {
13
7
  if (choice !== "once") await saveTrust(choice === "parent" ? dirname(workspace) : workspace, choice !== "never");
14
8
  return choice !== "never";
15
9
  }
16
- export class TrustSelector extends SelectList {
17
- private readonly workspace: string;
18
- private readonly decision: Awaited<ReturnType<typeof projectTrust>>;
19
- constructor(workspace: string, decision: Awaited<ReturnType<typeof projectTrust>>, style: Style) {
20
- super([
21
- { value: "once", label: "Trust for this session", description: "Load project resources without saving a decision" },
22
- { value: "here", label: "Trust this directory", description: workspace },
23
- { value: "parent", label: "Trust parent directory", description: dirname(workspace) },
24
- { value: "never", label: "Do not trust this directory", description: "Skip project configuration and executable extensions" },
25
- ], { title: "Project trust", search: false, style,
26
- ...(decision.saved ? { selectedValue: decision.saved.trusted ? decision.saved.path === workspace ? "here" : "parent" : "never" } : {}) });
27
- this.workspace = workspace; this.decision = decision;
28
- }
29
- override render(width: number): string[] {
30
- const saved = this.decision.saved;
31
- return ["Project trust", this.workspace, `Saved: ${saved ? `${saved.trusted ? "trusted" : "untrusted"} (${saved.path})` : "none"}`,
32
- `Current session: ${this.decision.trusted ? "trusted" : "untrusted"} · Tool permissions are separate`, ...super.render(width).slice(1)]
33
- .map(line => truncateToWidth(line, width, ""));
34
- }
35
- }
36
- export async function pickProjectTrust(workspace: string, handoff?: () => void): Promise<boolean> {
37
- const decision = await projectTrust(workspace);
38
- installBindings((await loadUiResources({ workspace, trusted: false })).keymap.byAction);
39
- const screen = createInteractiveTui();
40
- const select = new TrustSelector(workspace, decision, IDENTITY);
41
- screen.addChild(select); screen.setFocus(select);
42
- try {
43
- const choice = await new Promise<string | null>(resolve => {
44
- select.onInput = data => { const state = select.acceptInput(data); if (state.cancelled) resolve(null); else if (state.confirmed) resolve(state.confirmed.value); screen.requestRender(); };
45
- handoff?.(); screen.start();
46
- });
47
- return choice === null ? false : applyTrustChoice(workspace, choice as TrustChoice);
48
- } finally { await screen.terminal.drainInput(); screen.stop(); }
49
- }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Custom endpoints and connected-provider management, reached from `/login`.
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 `/login`. 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 /login 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 /login) 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;