@visual-z/xeno 0.2.0 → 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 (259) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +52 -9
  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/cli.ts +10 -11
  8. package/src/apps/commands.ts +38 -5
  9. package/src/apps/completions.ts +73 -19
  10. package/src/apps/image-draft.ts +46 -0
  11. package/src/apps/project-trust.ts +2 -42
  12. package/src/apps/provider-connect.ts +346 -0
  13. package/src/apps/render.ts +22 -47
  14. package/src/apps/resources.ts +5 -2
  15. package/src/apps/shell.ts +298 -1526
  16. package/src/apps/startup-input.ts +130 -0
  17. package/src/apps/startup.ts +143 -115
  18. package/src/apps/ui/app.ts +1383 -0
  19. package/src/apps/ui/extension-host.ts +194 -0
  20. package/src/apps/ui/image-cache.ts +78 -0
  21. package/src/apps/ui/palette.ts +88 -0
  22. package/src/apps/ui/project.ts +127 -0
  23. package/src/apps/ui/standalone.ts +146 -0
  24. package/src/apps/ui/status-model.ts +68 -0
  25. package/src/apps/ui/surface.ts +84 -0
  26. package/src/apps/ui/theme.ts +86 -0
  27. package/src/core/acceptance.ts +107 -0
  28. package/src/core/action-defaults.ts +47 -295
  29. package/src/core/benchmark.ts +2 -2
  30. package/src/core/branch-receipt.ts +24 -0
  31. package/src/core/checkpoints.ts +64 -0
  32. package/src/core/churn.ts +2 -0
  33. package/src/core/command-artifacts.ts +94 -0
  34. package/src/core/command-contract.ts +35 -0
  35. package/src/core/completion-review.ts +21 -5
  36. package/src/core/context-detail.ts +37 -0
  37. package/src/core/context-policy.ts +74 -1
  38. package/src/core/continuation.ts +17 -2
  39. package/src/core/derive.ts +29 -3
  40. package/src/core/draft-images.ts +56 -0
  41. package/src/core/environment.ts +145 -0
  42. package/src/core/evaluation-coverage.ts +74 -0
  43. package/src/core/event-validation.ts +80 -5
  44. package/src/core/events.ts +97 -3
  45. package/src/core/evidence-links.ts +7 -0
  46. package/src/core/evidence.ts +13 -3
  47. package/src/core/execution.ts +8 -2
  48. package/src/core/experiment.ts +133 -0
  49. package/src/core/export.ts +9 -0
  50. package/src/core/failure-recovery.ts +173 -0
  51. package/src/core/file-precondition.ts +72 -0
  52. package/src/core/handoff.ts +65 -4
  53. package/src/core/history-query.ts +38 -2
  54. package/src/core/history.ts +38 -1
  55. package/src/core/integration-health.ts +34 -0
  56. package/src/core/keymap.ts +8 -20
  57. package/src/core/model-picker.ts +64 -0
  58. package/src/core/native-eval.ts +25 -0
  59. package/src/core/note-guidance.ts +34 -0
  60. package/src/core/observation-view.ts +49 -8
  61. package/src/core/permissions.ts +24 -2
  62. package/src/core/process-receipt.ts +105 -0
  63. package/src/core/process-state.ts +37 -0
  64. package/src/core/project.ts +45 -18
  65. package/src/core/prompt-policy.ts +37 -0
  66. package/src/core/query-eval-contract.ts +14 -0
  67. package/src/core/read-observation.ts +82 -0
  68. package/src/core/recovery-corpus.ts +17 -0
  69. package/src/core/recovery-evaluation.ts +60 -0
  70. package/src/core/recovery-sources.ts +74 -0
  71. package/src/core/runtime-target.ts +94 -0
  72. package/src/core/search-observation.ts +97 -0
  73. package/src/core/session-tree.ts +147 -0
  74. package/src/core/settings.ts +26 -10
  75. package/src/core/skills.ts +44 -0
  76. package/src/core/sql-query.ts +211 -0
  77. package/src/core/theme.ts +36 -58
  78. package/src/core/tool-contract.ts +51 -2
  79. package/src/core/tool-discovery.ts +87 -0
  80. package/src/core/tool-failure.ts +60 -0
  81. package/src/core/tool-mode-protocol.ts +25 -0
  82. package/src/core/transcript.ts +19 -4
  83. package/src/core/trial-lifecycle.ts +14 -0
  84. package/src/core/tui-mode.ts +29 -0
  85. package/src/core/update-check.ts +97 -0
  86. package/src/core/version.ts +55 -0
  87. package/src/core/work-state.ts +49 -3
  88. package/src/core/working-set.ts +91 -0
  89. package/src/runtime/auth.ts +2 -2
  90. package/src/runtime/branch-jobs.ts +36 -0
  91. package/src/runtime/clipboard.ts +15 -4
  92. package/src/runtime/command-artifacts.ts +64 -0
  93. package/src/runtime/environment-tools.ts +60 -0
  94. package/src/runtime/evidence-notes.ts +4 -2
  95. package/src/runtime/exec-sessions.ts +206 -61
  96. package/src/runtime/execution-control.ts +1 -0
  97. package/src/runtime/extension-ui.ts +22 -30
  98. package/src/runtime/extensions.ts +95 -16
  99. package/src/runtime/harness-comparison.ts +27 -7
  100. package/src/runtime/harness-eval.ts +460 -0
  101. package/src/runtime/history-cache.ts +57 -0
  102. package/src/runtime/history-tools.ts +62 -12
  103. package/src/runtime/integrations.ts +210 -0
  104. package/src/runtime/loop.ts +1021 -268
  105. package/src/runtime/model-discovery.ts +208 -0
  106. package/src/runtime/native-eval-fixtures.ts +33 -0
  107. package/src/runtime/native-eval.ts +143 -0
  108. package/src/runtime/oauth.ts +1 -1
  109. package/src/runtime/provider-connect.ts +109 -0
  110. package/src/runtime/provider-presets.ts +87 -0
  111. package/src/runtime/query-eval-verifier.ts +28 -0
  112. package/src/runtime/recovery-coverage.ts +71 -0
  113. package/src/runtime/recovery-evidence.ts +76 -0
  114. package/src/runtime/recovery-fixtures.ts +116 -0
  115. package/src/runtime/runtime-target.ts +149 -0
  116. package/src/runtime/tool-broker.ts +137 -0
  117. package/src/runtime/tool-discovery.ts +57 -0
  118. package/src/runtime/tool-mode-eval.ts +146 -0
  119. package/src/runtime/tool-queue.ts +77 -0
  120. package/src/runtime/tools.ts +628 -183
  121. package/src/runtime/trial-control.ts +45 -0
  122. package/src/runtime/update-check.ts +47 -0
  123. package/src/runtime/window-trial.ts +2 -1
  124. package/src/runtime/working-notes.ts +33 -3
  125. package/src/runtime/workspace-lifecycle.ts +97 -0
  126. package/src/store/acceptance.ts +15 -0
  127. package/src/store/checkpoint-history.ts +43 -0
  128. package/src/store/command-artifacts.ts +96 -0
  129. package/src/store/command.ts +65 -37
  130. package/src/store/file-checkpoints.ts +104 -0
  131. package/src/store/file-index.ts +13 -1
  132. package/src/store/git-checkpoints.ts +67 -0
  133. package/src/store/observations.ts +61 -20
  134. package/src/store/process-receipts.ts +34 -0
  135. package/src/store/query-source.ts +13 -8
  136. package/src/store/recovery.ts +2 -3
  137. package/src/store/replay.ts +16 -4
  138. package/src/store/runtime-target.ts +64 -0
  139. package/src/store/search.ts +171 -132
  140. package/src/store/session-archive.ts +4 -0
  141. package/src/store/source-cancellation.ts +6 -0
  142. package/src/store/update-cache.ts +36 -0
  143. package/src/store/workspace-observation.ts +113 -0
  144. package/src/tui/ansi-spans.ts +126 -0
  145. package/src/tui/style.ts +19 -16
  146. package/src/tui/ui/atomic-draft.ts +121 -0
  147. package/src/tui/ui/canvas.ts +96 -0
  148. package/src/tui/ui/colors.ts +70 -0
  149. package/src/tui/ui/composer.ts +199 -0
  150. package/src/tui/ui/dialog.ts +379 -0
  151. package/src/tui/ui/draft.ts +23 -0
  152. package/src/tui/ui/edge.ts +186 -0
  153. package/src/tui/ui/image-size.ts +40 -0
  154. package/src/tui/ui/ink.ts +210 -0
  155. package/src/tui/ui/items.ts +40 -0
  156. package/src/tui/ui/keys.ts +40 -0
  157. package/src/tui/ui/layout.ts +74 -0
  158. package/src/tui/ui/list-model.ts +86 -0
  159. package/src/tui/ui/list-view.ts +172 -0
  160. package/src/tui/ui/mascot.ts +67 -0
  161. package/src/tui/ui/messages.ts +518 -0
  162. package/src/tui/ui/motion.ts +28 -0
  163. package/src/tui/ui/paths.ts +10 -0
  164. package/src/tui/ui/pictures.ts +78 -0
  165. package/src/tui/ui/screen.ts +421 -0
  166. package/src/tui/ui/status.ts +271 -0
  167. package/src/tui/ui/styled.ts +85 -0
  168. package/src/tui/ui/testing.ts +5 -0
  169. package/src/tui/ui/tool-card.ts +150 -0
  170. package/src/apps/extension-ui.ts +0 -330
  171. package/src/apps/resource-browser.ts +0 -75
  172. package/src/apps/session-browser.ts +0 -113
  173. package/src/apps/session-picker.ts +0 -55
  174. package/src/tui/ansi.ts +0 -67
  175. package/src/tui/autocomplete.ts +0 -29
  176. package/src/tui/chat-viewport.ts +0 -47
  177. package/src/tui/complete.ts +0 -87
  178. package/src/tui/editor.ts +0 -940
  179. package/src/tui/host.ts +0 -264
  180. package/src/tui/input.ts +0 -75
  181. package/src/tui/keys.ts +0 -378
  182. package/src/tui/markdown.ts +0 -17
  183. package/src/tui/render.ts +0 -153
  184. package/src/tui/select.ts +0 -258
  185. package/src/tui/settings.ts +0 -44
  186. package/src/tui/statusline.ts +0 -162
  187. package/src/tui/system-theme.ts +0 -700
  188. package/src/tui/terminal.ts +0 -30
  189. package/src/tui/text.ts +0 -52
  190. package/src/tui/toolview.ts +0 -386
  191. package/src/tui/transcript.ts +0 -123
  192. package/src/tui/tree.ts +0 -471
  193. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  194. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  195. package/src/vendor/pi-tui/VENDOR.md +0 -25
  196. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  197. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  198. package/src/vendor/pi-tui/colors.ts +0 -367
  199. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  200. package/src/vendor/pi-tui/components/box.ts +0 -166
  201. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  202. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  203. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  204. package/src/vendor/pi-tui/components/image.ts +0 -127
  205. package/src/vendor/pi-tui/components/input.ts +0 -494
  206. package/src/vendor/pi-tui/components/loader.ts +0 -101
  207. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  208. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  209. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  210. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  211. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  212. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  213. package/src/vendor/pi-tui/components/stack.ts +0 -154
  214. package/src/vendor/pi-tui/components/text.ts +0 -107
  215. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  216. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  217. package/src/vendor/pi-tui/editor-component.ts +0 -74
  218. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  219. package/src/vendor/pi-tui/index.ts +0 -186
  220. package/src/vendor/pi-tui/keybindings.ts +0 -320
  221. package/src/vendor/pi-tui/keys.ts +0 -1401
  222. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  223. package/src/vendor/pi-tui/latex.ts +0 -1506
  224. package/src/vendor/pi-tui/layout-node.ts +0 -51
  225. package/src/vendor/pi-tui/layout.ts +0 -449
  226. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  227. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  228. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  229. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  232. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  233. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  234. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  237. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  238. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  239. package/src/vendor/pi-tui/native/napi.h +0 -91
  240. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  241. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  244. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  245. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  246. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  247. package/src/vendor/pi-tui/native-platform.ts +0 -65
  248. package/src/vendor/pi-tui/oklab.ts +0 -233
  249. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  250. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  251. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  252. package/src/vendor/pi-tui/terminal.ts +0 -554
  253. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  254. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  255. package/src/vendor/pi-tui/tui.ts +0 -1493
  256. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  257. package/src/vendor/pi-tui/utils.ts +0 -1385
  258. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  259. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -1,31 +1,85 @@
1
- import { COMMANDS } from "./commands.ts";
1
+ /**
2
+ * What the composer offers for the text at the cursor. Pure given its
3
+ * sources, so the three cases are tested without a terminal:
4
+ * "/mo|" command names (the palette's own entries)
5
+ * "/thinking h|" the command's argument values
6
+ * "see @src/ap|" workspace paths (ranked fuzzy, quoted when they contain spaces)
7
+ * `@` inside a word (an e-mail) or after a backslash is not a reference.
8
+ */
2
9
  import type { App } from "./startup.ts";
3
- import { WorkspaceAutocomplete } from "../tui/autocomplete.ts";
4
10
  import { SUBSCRIPTION_OPTIONS } from "../runtime/oauth.ts";
5
- import { modelKey } from "../core/session-position.ts";
6
11
  import { supportedThinkingLevels } from "../core/thinking.ts";
7
12
  import { SETTINGS_KEYS, DISPLAY_SETTINGS } from "../core/settings.ts";
13
+ import { fuzzyFilter } from "../tui/fuzzy.ts";
14
+ import { COMMANDS } from "./commands.ts";
15
+ import type { Completion } from "../tui/ui/composer.ts";
16
+ import type { ListItem } from "../tui/ui/list-model.ts";
17
+
18
+ export interface CompletionSources {
19
+ readonly commands: readonly { name: string; description: string }[];
20
+ readonly argumentValues: (command: string) => readonly string[];
21
+ readonly paths: readonly string[];
22
+ }
23
+
24
+ /** Twelve rows: enough to see siblings of a path without hiding the composer. Assumed. */
25
+ const MAX_ITEMS = 12;
8
26
 
9
- export function interactiveAutocomplete(app: App, paths: (refresh?: boolean) => Promise<readonly string[]>): WorkspaceAutocomplete {
10
- const providers = [...new Set([...app.catalog.map((m) => m.provider), ...SUBSCRIPTION_OPTIONS.map((p) => p.id)])];
27
+ export function completionAt(text: string, cursor: number, sources: CompletionSources): Completion | undefined {
28
+ const before = text.slice(0, cursor);
29
+ const line = before.slice(before.lastIndexOf("\n") + 1);
30
+ const lineStart = cursor - line.length;
31
+ if (line.startsWith("/") && lineStart === 0) {
32
+ const space = line.indexOf(" ");
33
+ if (space < 0) {
34
+ const query = line.slice(1).toLowerCase();
35
+ const items = sources.commands
36
+ .filter(command => command.name.toLowerCase().startsWith(query) || (query.length > 1 && command.name.toLowerCase().includes(query)))
37
+ .map(command => ({ value: `/${command.name} `, label: `/${command.name}`, description: command.description }));
38
+ return items.length ? { start: 0, end: cursor, items: items.slice(0, MAX_ITEMS) } : undefined;
39
+ }
40
+ const command = line.slice(1, space);
41
+ const arg = line.slice(space + 1);
42
+ if (arg.includes(" ")) return undefined;
43
+ const values = sources.argumentValues(command);
44
+ if (!values.length) return undefined;
45
+ const items = fuzzyFilter(values, arg).slice(0, MAX_ITEMS).map(value => ({ value, label: value }));
46
+ return items.length ? { start: space + 1, end: cursor, items } : undefined;
47
+ }
48
+ const at = line.lastIndexOf("@");
49
+ if (at < 0) return undefined;
50
+ const prev = line[at - 1];
51
+ if (prev !== undefined && !/\s/.test(prev)) return undefined;
52
+ const query = line.slice(at + 1);
53
+ if (/\s/.test(query) && !query.startsWith("\"")) return undefined;
54
+ const bare = query.replace(/^"/, "");
55
+ const items: ListItem[] = fuzzyFilter(sources.paths, bare).slice(0, MAX_ITEMS)
56
+ .map(path => ({ value: `@${/\s/.test(path) ? JSON.stringify(path) : path} `, label: path }));
57
+ return items.length ? { start: lineStart + at, end: cursor, items } : undefined;
58
+ }
59
+
60
+ export function completionSources(app: App, paths: readonly string[]): CompletionSources {
61
+ const providers = [...new Set([...app.catalog.map(m => m.provider), ...SUBSCRIPTION_OPTIONS.map(p => p.id)])];
11
62
  const values: Record<string, () => string[]> = {
12
- model: () => app.catalog.map(modelKey),
63
+ // Model identities belong to the searchable picker. The generic completer
64
+ // still handles arbitrary argument sources; this app supplies only one
65
+ // model selection surface, rather than a second list with different keys.
13
66
  thinking: () => [...supportedThinkingLevels(app.providerConfig.spec)],
14
- login: () => providers, logout: () => providers,
67
+ login: () => providers, connect: () => providers, logout: () => Object.keys(app.auth),
15
68
  settings: () => [...new Set([...SETTINGS_KEYS, ...DISPLAY_SETTINGS.map(item => item.id)])],
16
69
  models: () => ["refresh"], trust: () => ["once", "here", "parent", "never"],
17
70
  };
18
- const commands = COMMANDS.map((definition) => ({
19
- ...definition,
20
- getArgumentCompletions(prefix: string) {
21
- return (values[definition.name]?.() ?? []).filter((v) => v.toLowerCase().includes(prefix.toLowerCase()))
22
- .map((v) => ({ value: v, label: v }));
23
- },
24
- }));
25
- const registered = [
26
- ...[...app.extensions?.commands ?? []].map(([name, command]) => ({ name, ...command })),
27
- ...[...(app.slash?.prompts.values() ?? [])].map((p) => ({ name: p.name, description: p.description })),
28
- ...(app.slash?.skillCommands === false ? [] : [...(app.slash?.skills.values() ?? [])].map((s) => ({ name: `skill:${s.name}`, description: s.description }))),
71
+ return {
72
+ commands: [...COMMANDS.filter(command => command.name !== "exit"), ...commandEntries(app)],
73
+ argumentValues: command => values[command]?.() ?? [],
74
+ paths,
75
+ };
76
+ }
77
+
78
+ /** Built-ins, then extension commands, prompt templates and skills: the same names the palette lists. */
79
+ export function commandEntries(app: App): { name: string; description: string; section: string }[] {
80
+ return [
81
+ ...[...app.extensions?.commands ?? []].map(([name, command]) => ({ name, description: command.description ?? "extension command", section: "Extensions" })),
82
+ ...[...(app.slash?.prompts.values() ?? [])].map(p => ({ name: p.name, description: p.description ?? "prompt template", section: "Prompts" })),
83
+ ...(app.slash?.skillCommands === false ? [] : [...(app.slash?.skills.values() ?? [])].map(s => ({ name: `skill:${s.name}`, description: s.description ?? "skill", section: "Skills" }))),
29
84
  ];
30
- return new WorkspaceAutocomplete([...commands, ...registered], app.workspace, paths);
31
85
  }
@@ -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): 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
- 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 =