@visual-z/xeno 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -1,25 +1,35 @@
1
1
  /** Human history is a separate pure log projection from the model's bounded context (D1, D8). */
2
2
  import type { LogEvent } from "./events.ts";
3
3
  import { assertNever } from "./exhaustive.ts";
4
+ import type { ImageRef } from "./images.ts";
4
5
 
5
6
  type Identity = { id: string; revision: string };
6
7
  export type TranscriptItem = Identity & (
7
- | { kind: "user"; text: string; interrupted: boolean }
8
+ | { kind: "user"; text: string; interrupted: boolean; images?: readonly ImageRef[] }
8
9
  | { kind: "assistant" | "thinking" | "notice"; text: string }
9
- | { kind: "tool"; callId: string; name: string; input: unknown; result: { text: string; ok: boolean } | null }
10
+ | { kind: "custom"; text: string; customType: string; data?: unknown; entry: boolean; timestamp: string }
11
+ | { kind: "tool"; callId: string; name: string; input: unknown; result: { text: string; ok: boolean; data?: unknown; images?: readonly ImageRef[] } | null }
10
12
  );
11
13
 
12
- export function projectTranscript(events: readonly LogEvent[]): TranscriptItem[] {
14
+ export function projectTranscript(events: readonly LogEvent[], entryTypes: ReadonlySet<string> = new Set()): TranscriptItem[] {
13
15
  const items: TranscriptItem[] = [];
14
16
  const calls = new Map<string, number>();
15
17
  const users = new Map<string, number>();
16
18
  for (const event of events) {
17
19
  const identity = { id: event.id, revision: event.id };
18
20
  switch (event.t) {
21
+ case "extension":
22
+ if (event.display || !event.inContext && entryTypes.has(event.customType)) items.push({ ...identity, kind: "custom", text: event.content, customType: event.customType,
23
+ entry: !event.inContext, timestamp: event.at, ...(event.data === undefined ? {} : { data: event.data }) });
24
+ break;
19
25
  case "user":
20
26
  users.set(event.id, items.length);
21
- items.push({ ...identity, kind: "user", text: event.text, interrupted: false });
27
+ items.push({ ...identity, kind: "user", text: event.text, interrupted: false, ...(event.images?.length ? { images: event.images } : {}) });
22
28
  break;
29
+ case "user_bash": items.push({ ...identity, kind: "notice",
30
+ text: `${event.excludeFromContext ? "!!" : "!"}${event.command}\n${event.content}` }); break;
31
+ case "outcome": items.push({ ...identity, kind: "notice",
32
+ text: `${event.outcome.status} · ${event.outcome.reason} · ${event.reason}` }); break;
23
33
  case "assistant":
24
34
  event.blocks.forEach((block, i) => {
25
35
  const id = `${event.id}:${i}`;
@@ -33,7 +43,7 @@ export function projectTranscript(events: readonly LogEvent[]): TranscriptItem[]
33
43
  const index = calls.get(event.call_id);
34
44
  const call = index === undefined ? undefined : items[index];
35
45
  if (call?.kind === "tool" && index !== undefined) {
36
- items[index] = { ...call, revision: event.id, result: { text: event.content, ok: event.ok } };
46
+ items[index] = { ...call, revision: event.id, result: { text: event.content, ok: event.ok, ...(event.data === undefined ? {} : { data: event.data }), ...(event.images?.length ? { images: event.images } : {}) } };
37
47
  } else items.push({ ...identity, kind: "notice", text: `Unpaired tool result ${event.call_id}: ${event.content}` });
38
48
  break;
39
49
  }
@@ -13,13 +13,13 @@
13
13
  * still winning. pi states this rule outright in its resolver; it is worth
14
14
  * copying verbatim.
15
15
  *
16
- * The order is pi's: command line, then `auth.json`, then the environment.
17
- * A fourth position belongs to custom providers declared in `models.json`,
18
- * which this build does not have yet.
16
+ * The order is command line, auth.json, models.json, then the environment.
19
17
  */
20
18
 
21
19
  import { assertNever } from "../core/exhaustive.ts";
22
20
  import type { ModelSpec } from "./model.ts";
21
+ import { providerAccess } from "./provider-access.ts";
22
+ import { SOURCE_PROVIDERS } from "./model-data.ts";
23
23
  import {
24
24
  refreshOAuthCredential,
25
25
  type OAuthCredential,
@@ -40,12 +40,18 @@ export interface AuthFile {
40
40
  }
41
41
 
42
42
  export function storedIsOAuth(stored: AuthFile, provider: string): boolean {
43
- const entry = stored[provider];
43
+ const entry = storedCredential(stored, provider);
44
44
  return entry !== undefined && entry.type === "oauth";
45
45
  }
46
46
 
47
+ function storedCredential(stored: AuthFile, provider: string): StoredCredential | undefined {
48
+ // Earlier xeno stored Codex subscriptions under openai. Only OAuth gets this read-compatible alias;
49
+ // API keys and explicit native records retain their own provider and billing account.
50
+ return stored[provider] ?? (provider === "openai-codex" && stored["openai"]?.type === "oauth" ? stored["openai"] : undefined);
51
+ }
52
+
47
53
  export type Resolved =
48
- | { ok: true; key: string; source: "flag" | "auth.json" | "environment" }
54
+ | { ok: true; key: string; source: "flag" | "auth.json" | "models.json" | "environment" | "ambient" }
49
55
  | { ok: false; reason: string };
50
56
 
51
57
  /** The two effects resolution needs, injected so the rules stay testable. */
@@ -127,21 +133,78 @@ export function resolveKeyValue(
127
133
  }
128
134
  }
129
135
 
136
+ /** Per-provider variables resolve lazily, so order cannot change their meaning or mutate process.env. */
137
+ export function providerEnvironment(spec: ModelSpec, fx: ResolveEffects): { env: Record<string, string>; effects: ResolveEffects } {
138
+ const configured = providerAccess(spec).env ?? {};
139
+ const env: Record<string, string> = {};
140
+ const resolving = new Set<string>();
141
+ const lookup = (name: string): string | undefined => {
142
+ if (Object.hasOwn(env, name)) return env[name];
143
+ const raw = configured[name];
144
+ if (raw === undefined) return fx.env(name);
145
+ if (resolving.has(name)) throw new Error(`${spec.provider} env.${name}: cyclic variable reference`);
146
+ resolving.add(name);
147
+ try {
148
+ const got = resolveKeyValue(raw, { ...fx, env: lookup });
149
+ if (!got.ok) throw new Error(`${spec.provider} env.${name}: could not resolve configured value`);
150
+ env[name] = got.key;
151
+ return got.key;
152
+ } finally { resolving.delete(name); }
153
+ };
154
+ for (const name of Object.keys(configured)) lookup(name);
155
+ const aliases: Record<string, readonly string[]> = {
156
+ AZURE_OPENAI_API_KEY: ["AZURE_API_KEY"], AZURE_OPENAI_RESOURCE_NAME: ["AZURE_RESOURCE_NAME"], AZURE_OPENAI_BASE_URL: ["AZURE_BASE_URL"],
157
+ GOOGLE_CLOUD_PROJECT: ["GOOGLE_VERTEX_PROJECT", "GCLOUD_PROJECT"], GOOGLE_CLOUD_LOCATION: ["GOOGLE_VERTEX_LOCATION"],
158
+ };
159
+ for (const [target, sources] of Object.entries(aliases)) {
160
+ const value = lookup(target) ?? sources.map(lookup).find(Boolean);
161
+ if (value) env[target] = value;
162
+ }
163
+ return { env, effects: { ...fx, env: lookup } };
164
+ }
165
+
166
+ function configuredEffects(spec: ModelSpec, fx: ResolveEffects): { ok: true; effects: ResolveEffects } | { ok: false; reason: string } {
167
+ if (providerAccess(spec).invalid) return { ok: false, reason: providerAccess(spec).invalid! };
168
+ try {
169
+ const scoped = providerEnvironment(spec, fx).effects;
170
+ const access = providerAccess(spec);
171
+ if (["google-generative-ai", "google-vertex"].includes(spec.api) && (access.auth === false || access.authHeader === false || typeof access.authHeader === "string")) {
172
+ return { ok: false, reason: `${spec.api} owns API-key authentication; use apiKey or an OpenAI-compatible local endpoint` };
173
+ }
174
+ if (!spec.baseUrl && !["anthropic-messages", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "azure-openai-responses"].includes(spec.api)) {
175
+ return { ok: false, reason: `${spec.provider}: configure baseUrl for ${spec.api}` };
176
+ }
177
+ if (spec.baseUrl?.includes("$")) {
178
+ const endpoint = resolveKeyValue(spec.baseUrl, scoped);
179
+ if (!endpoint.ok) return { ok: false, reason: `${spec.provider} baseUrl: ${endpoint.reason}` };
180
+ }
181
+ if (spec.api === "azure-openai-responses" && !spec.baseUrl && !scoped.env("AZURE_OPENAI_BASE_URL") && !scoped.env("AZURE_OPENAI_RESOURCE_NAME")) {
182
+ return { ok: false, reason: `${spec.provider}: configure baseUrl or AZURE_OPENAI_RESOURCE_NAME` };
183
+ }
184
+ return { ok: true, effects: scoped };
185
+ } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : "could not resolve provider environment" }; }
186
+ }
187
+
130
188
  export function resolveCredential(
131
189
  spec: ModelSpec,
132
190
  stored: AuthFile,
133
191
  flagKey: string | undefined,
134
192
  fx: ResolveEffects,
135
193
  ): Resolved {
194
+ const access = providerAccess(spec);
195
+ const configured = configuredEffects(spec, fx);
196
+ if (!configured.ok) return configured;
197
+ fx = configured.effects;
136
198
  if (flagKey !== undefined && flagKey.length > 0) {
137
199
  const got = resolveKeyValue(flagKey, fx);
138
200
  return got.ok ? { ok: true, key: got.key, source: "flag" } : got;
139
201
  }
140
202
 
141
- const entry = stored[spec.provider];
203
+ const entry = storedCredential(stored, spec.provider);
142
204
  if (entry !== undefined) {
143
205
  // Deliberately no fallback to the environment on failure. See the header.
144
206
  if (entry.type === "oauth") {
207
+ if (spec.provider === "openai" && spec.api !== "openai-codex-responses") return { ok: false, reason: "ChatGPT OAuth belongs to openai-codex; choose an openai-codex model or configure an OpenAI API key" };
145
208
  return { ok: true, key: entry.access, source: "auth.json" };
146
209
  }
147
210
  if (entry.type === "api_key") {
@@ -153,13 +216,33 @@ export function resolveCredential(
153
216
  return assertNever(entry, "stored credential");
154
217
  }
155
218
 
156
- for (const variable of envVarsOf(spec.provider)) {
219
+ if (access.apiKey !== undefined) {
220
+ const got = resolveKeyValue(access.apiKey, fx);
221
+ return got.ok ? { ok: true, key: got.key, source: "models.json" }
222
+ : { ok: false, reason: `${spec.provider} in models.json: could not resolve apiKey` };
223
+ }
224
+ if (access.auth === false || access.authHeader === false) return { ok: true, key: "", source: "models.json" };
225
+ if (access.auth === "ambient") {
226
+ if (spec.api !== "bedrock-converse-stream" && spec.api !== "google-vertex") return { ok: false, reason: `${spec.api} does not support ambient credentials` };
227
+ if (spec.api === "google-vertex" && (!fx.env("GOOGLE_CLOUD_PROJECT") || !fx.env("GOOGLE_CLOUD_LOCATION"))) return { ok: false, reason: `${spec.provider}: ambient Vertex authentication requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION` };
228
+ return { ok: true, key: "", source: "ambient" };
229
+ }
230
+ for (const variable of envVarsOf(spec.provider, spec.credentialEnv)) {
157
231
  const value = fx.env(variable);
158
232
  if (value !== undefined && value.length > 0) {
159
233
  return { ok: true, key: value, source: "environment" };
160
234
  }
161
235
  }
162
236
 
237
+ if (spec.api === "bedrock-converse-stream" && (fx.env("AWS_PROFILE")
238
+ || (fx.env("AWS_ACCESS_KEY_ID") && fx.env("AWS_SECRET_ACCESS_KEY"))
239
+ || fx.env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || fx.env("AWS_CONTAINER_CREDENTIALS_FULL_URI") || fx.env("AWS_WEB_IDENTITY_TOKEN_FILE"))) {
240
+ return { ok: true, key: "", source: "ambient" };
241
+ }
242
+ if (spec.api === "google-vertex" && fx.env("GOOGLE_APPLICATION_CREDENTIALS")
243
+ && (fx.env("GOOGLE_CLOUD_PROJECT") || fx.env("GOOGLE_VERTEX_PROJECT") || fx.env("GCLOUD_PROJECT"))
244
+ && (fx.env("GOOGLE_CLOUD_LOCATION") || fx.env("GOOGLE_VERTEX_LOCATION"))) return { ok: true, key: "", source: "ambient" };
245
+
163
246
  return { ok: false, reason: describeMissingCredential(spec.provider) };
164
247
  }
165
248
 
@@ -180,14 +263,32 @@ export const ENV_VAR: Readonly<Record<string, string | readonly string[]>> = {
180
263
  zai: "ZHIPU_API_KEY",
181
264
  moonshotai: "MOONSHOT_API_KEY",
182
265
  together: "TOGETHER_API_KEY",
266
+ "amazon-bedrock": "AWS_BEARER_TOKEN_BEDROCK",
267
+ "azure-openai-responses": ["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
268
+ "google-vertex": "GOOGLE_CLOUD_API_KEY",
269
+ "kimi-coding": ["KIMI_CODING_API_KEY", "KIMI_API_KEY"],
270
+ "openai-codex": "OPENAI_CODEX_ACCESS_TOKEN",
271
+ "ant-ling": "ANT_LING_API_KEY",
272
+ "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY",
273
+ "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY",
274
+ "qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY",
275
+ "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
276
+ "zai-coding-cn": "ZAI_CODING_CN_API_KEY",
277
+ "fireworks": "FIREWORKS_API_KEY",
278
+ "typesafe": "TYPESAFE_API_KEY",
279
+ "radius": "RADIUS_API_KEY",
280
+ "opencode-go": "OPENCODE_API_KEY",
183
281
  };
184
282
 
185
- export function envVarsOf(provider: string): readonly string[] {
283
+ export function envVarsOf(provider: string, additional: readonly string[] = []): readonly string[] {
186
284
  const v = ENV_VAR[provider];
187
285
  // A provider name is free text in a user catalog; an environment variable
188
286
  // is not. `kimi-coding` must become KIMI_CODING, or no shell can set it.
189
- if (v === undefined) return [`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`];
190
- return typeof v === "string" ? [v] : v;
287
+ const declared = [...additional, ...(SOURCE_PROVIDERS[provider]?.env ?? [])].filter(name => /(?:API_KEY|TOKEN|_KEY)$/.test(name)
288
+ && !/(?:SECRET_ACCESS_KEY|ACCESS_KEY_ID|RESOURCE|PROJECT|LOCATION)/.test(name));
289
+ const known = typeof v === "string" ? [v] : v ?? [];
290
+ const names = [...new Set([...known, ...declared])];
291
+ return names.length ? names : [`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`];
191
292
  }
192
293
 
193
294
  export function envLabel(provider: string): string {
@@ -227,11 +328,15 @@ export async function ensureFreshCredential(
227
328
  fx: ResolveEffects,
228
329
  oauth: FreshEffects,
229
330
  ): Promise<Resolved> {
331
+ const configured = configuredEffects(spec, fx);
332
+ if (!configured.ok) return configured;
230
333
  if (flagKey !== undefined && flagKey.length > 0) {
231
334
  return resolveCredential(spec, stored, flagKey, fx);
232
335
  }
233
- const entry = stored[spec.provider];
336
+ const entry = storedCredential(stored, spec.provider);
234
337
  if (entry !== undefined && entry.type === "oauth") {
338
+ const resolved = resolveCredential(spec, stored, flagKey, fx);
339
+ if (!resolved.ok) return resolved;
235
340
  if (oauth.now() < entry.expires) {
236
341
  return { ok: true, key: entry.access, source: "auth.json" };
237
342
  }
@@ -15,11 +15,17 @@
15
15
  */
16
16
 
17
17
  import { assertNever } from "../core/exhaustive.ts";
18
+ import { modelKey } from "../core/session-position.ts";
19
+ import { mergeCatalogs } from "../core/catalog-merge.ts";
20
+ import { parseModelConfiguration } from "./model-config.ts";
18
21
  import type { Settings } from "../core/settings.ts";
19
- import { CATALOG, type Api, type CacheKind, type CacheRates, type Compat, type ModelSpec, type Price, type Effort } from "./model.ts";
22
+ import { CATALOG, CURATED_CATALOG, type ModelSpec } from "./model.ts";
20
23
  import { AnthropicProvider, type Provider, type ProviderConfig } from "./provider.ts";
21
24
  import { OpenAICompletionsProvider } from "./openai.ts";
22
25
  import { OpenAIResponsesProvider } from "./openai-responses.ts";
26
+ import { PiProvider } from "./pi-provider.ts";
27
+ import { copyProviderAccess, hasProviderAccess } from "./provider-access.ts";
28
+ import type { ResolveEffects } from "./auth.ts";
23
29
 
24
30
  export type { Settings };
25
31
 
@@ -36,149 +42,39 @@ export type CatalogFetch = (input: RequestInfo | URL, init?: RequestInit) => Pro
36
42
  * startup-path test can inject one and assert it was never called — a default
37
43
  * `refresh: false` flag would not prove that.
38
44
  */
39
- export function loadCatalog(opts: {
40
- userText: string | null;
41
- fetch?: CatalogFetch;
42
- }): LoadedCatalog {
45
+ export function loadCatalog(opts: { userText: string | null; cacheText?: string | null; readWarnings?: readonly string[]; fetch?: CatalogFetch }): LoadedCatalog {
43
46
  void opts.fetch;
44
- if (opts.userText === null || opts.userText.trim().length === 0) {
45
- return { models: [...CATALOG], warnings: [] };
46
- }
47
- let json: unknown;
48
- try {
49
- json = JSON.parse(opts.userText);
50
- } catch (err) {
51
- return {
52
- models: [...CATALOG],
53
- warnings: [`user catalog is not valid JSON: ${(err as Error).message}`],
54
- };
55
- }
56
- const parsed = parseUserCatalog(json);
57
- return { models: overlayCatalog(CATALOG, parsed.models), warnings: parsed.errors };
58
- }
59
-
60
- /** Same id: user wins. User-only ids append, in file order. */
61
- export function overlayCatalog(
62
- builtin: readonly ModelSpec[],
63
- user: readonly ModelSpec[],
64
- ): ModelSpec[] {
65
- const byId = new Map<string, ModelSpec>();
66
- for (const spec of builtin) byId.set(spec.id, spec);
67
- const extra: ModelSpec[] = [];
68
- for (const spec of user) {
69
- if (byId.has(spec.id)) byId.set(spec.id, spec);
70
- else extra.push(spec);
71
- }
72
- return [...byId.values(), ...extra];
73
- }
74
-
75
- export function parseUserCatalog(json: unknown): { models: ModelSpec[]; errors: string[] } {
76
- const rows = catalogRows(json);
77
- if (rows.ok === false) return { models: [], errors: [rows.error] };
78
- const models: ModelSpec[] = [];
79
- const errors: string[] = [];
80
- for (const [i, row] of rows.rows.entries()) {
81
- const parsed = parseUserEntry(row, i);
82
- if (parsed.ok) models.push(parsed.spec);
83
- else errors.push(parsed.error);
84
- }
85
- return { models, errors };
86
- }
87
-
88
- function catalogRows(json: unknown): { ok: true; rows: unknown[] } | { ok: false; error: string } {
89
- if (Array.isArray(json)) return { ok: true, rows: json };
90
- if (typeof json === "object" && json !== null && "models" in json) {
91
- const models = (json as { models: unknown }).models;
92
- if (Array.isArray(models)) return { ok: true, rows: models };
93
- return { ok: false, error: "user catalog .models is not an array" };
94
- }
95
- return { ok: false, error: "user catalog must be an array or { models: [] }" };
96
- }
97
-
98
- function parseUserEntry(
99
- row: unknown,
100
- index: number,
101
- ): { ok: true; spec: ModelSpec } | { ok: false; error: string } {
102
- const where = `entry ${index + 1}`;
103
- if (typeof row !== "object" || row === null || Array.isArray(row)) {
104
- return { ok: false, error: `${where} is not an object` };
105
- }
106
- const raw = row as Record<string, unknown>;
107
- const id = typeof raw["id"] === "string" && raw["id"].length > 0 ? raw["id"] : null;
108
- const label = id ?? where;
109
- if (id === null) return { ok: false, error: `${where} has no id` };
110
- if (!isApi(raw["api"])) {
111
- return { ok: false, error: `${label} has an unknown or missing api` };
112
- }
113
- if (typeof raw["provider"] !== "string" || raw["provider"].length === 0) {
114
- return { ok: false, error: `${label} has no provider` };
47
+ const warnings: string[] = [...(opts.readWarnings ?? [])];
48
+ let models = [...CATALOG];
49
+ if (opts.cacheText?.trim()) {
50
+ try {
51
+ const parsed = parseModelConfiguration(JSON.parse(opts.cacheText));
52
+ const curated = new Map(CURATED_CATALOG.map(spec => [modelKey(spec), spec]));
53
+ const cached = parsed.models.map(spec => {
54
+ const known = curated.get(modelKey(spec));
55
+ if (!known) return spec;
56
+ // Refresh metadata without replacing already verified wire dialects for existing sessions.
57
+ const next = { ...spec, api: known.api, reasoning: known.reasoning };
58
+ delete next.driver; delete next.compat; delete next.nativeCompat; delete next.thinkingLevelMap;
59
+ if (known.compat) next.compat = known.compat;
60
+ if (known.thinkingLevelMap) next.thinkingLevelMap = known.thinkingLevelMap;
61
+ if (known.baseUrl !== undefined) next.baseUrl = known.baseUrl;
62
+ copyProviderAccess(spec, next);
63
+ return next;
64
+ });
65
+ models = mergeCatalogs(models, cached);
66
+ warnings.push(...parsed.errors.map(message => "catalog cache: " + message));
67
+ } catch { warnings.push("catalog cache is not valid JSON; using bundled models"); }
115
68
  }
116
- // Refused rather than dropped: without its map, an endpoint that accepts
117
- // only some effort levels gets the rest verbatim and answers with a 400.
118
- if (raw["thinkingLevelMap"] !== undefined && !isEffortMap(raw["thinkingLevelMap"])) {
119
- return { ok: false, error: `${label} has an invalid thinkingLevelMap (keys low|medium|high|xhigh|max, values string or null)` };
69
+ if (opts.userText?.trim()) {
70
+ try { const parsed = parseModelConfiguration(JSON.parse(opts.userText), models); models = parsed.models; warnings.push(...parsed.errors); }
71
+ catch { warnings.push("user catalog is not valid JSON; using bundled/cache models"); }
120
72
  }
121
- const spec: ModelSpec = {
122
- id,
123
- name: typeof raw["name"] === "string" && raw["name"].length > 0 ? raw["name"] : id,
124
- api: raw["api"],
125
- provider: raw["provider"],
126
- reasoning: raw["reasoning"] === true,
127
- ...(typeof raw["baseUrl"] === "string" ? { baseUrl: raw["baseUrl"] } : {}),
128
- ...(isCacheKind(raw["cache"]) ? { cache: raw["cache"] } : {}),
129
- ...(isCacheRates(raw["rates"]) ? { rates: raw["rates"] } : {}),
130
- ...(isPrice(raw["price"]) ? { price: raw["price"] } : {}),
131
- ...(typeof raw["contextWindow"] === "number" ? { contextWindow: raw["contextWindow"] } : {}),
132
- ...(typeof raw["maxTokens"] === "number" ? { maxTokens: raw["maxTokens"] } : {}),
133
- ...(isCompat(raw["compat"]) ? { compat: raw["compat"] } : {}),
134
- ...(isEffortMap(raw["thinkingLevelMap"]) ? { thinkingLevelMap: raw["thinkingLevelMap"] } : {}),
135
- };
136
- return { ok: true, spec };
73
+ return { models, warnings };
137
74
  }
138
75
 
139
- const EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
140
- function isEffortMap(v: unknown): v is Partial<Record<Effort, string | null>> {
141
- if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
142
- return Object.entries(v).every(([key, value]) => EFFORTS.includes(key) && (value === null || typeof value === "string"));
143
- }
144
-
145
- function isApi(v: unknown): v is Api {
146
- return v === "anthropic-messages" || v === "openai-completions" || v === "openai-responses";
147
- }
148
-
149
- function isCacheKind(v: unknown): v is CacheKind {
150
- return v === "explicit" || v === "automatic";
151
- }
152
-
153
- function isCacheRates(v: unknown): v is CacheRates {
154
- if (typeof v !== "object" || v === null) return false;
155
- const o = v as Record<string, unknown>;
156
- return typeof o["read"] === "number" && typeof o["write"] === "number";
157
- }
158
-
159
- function isPrice(v: unknown): v is Price {
160
- if (typeof v !== "object" || v === null) return false;
161
- const o = v as Record<string, unknown>;
162
- if (typeof o["input"] !== "number" || typeof o["output"] !== "number") return false;
163
- // Cache slots may be absent. A present non-number is a bad entry, not
164
- // "unknown cache" — that would let `{cacheRead: "free"}` through.
165
- if ("cacheRead" in o && typeof o["cacheRead"] !== "number") return false;
166
- if ("cacheWrite" in o && typeof o["cacheWrite"] !== "number") return false;
167
- return true;
168
- }
169
-
170
- function isCompat(v: unknown): v is Compat {
171
- if (typeof v !== "object" || v === null) return false;
172
- const o = v as Record<string, unknown>;
173
- return (
174
- typeof o["supportsDeveloperRole"] === "boolean" &&
175
- (o["maxTokensField"] === "max_tokens" || o["maxTokensField"] === "max_completion_tokens") &&
176
- typeof o["supportsReasoningEffort"] === "boolean" &&
177
- typeof o["supportsUsageInStreaming"] === "boolean" &&
178
- typeof o["requiresReasoningContentOnAssistantMessages"] === "boolean" &&
179
- (o["thinkingFormat"] === "openai" || o["thinkingFormat"] === "deepseek")
180
- );
181
- }
76
+ export function overlayCatalog(builtin: readonly ModelSpec[], user: readonly ModelSpec[]): ModelSpec[] { return mergeCatalogs(builtin, user); }
77
+ export function parseUserCatalog(json: unknown): { models: ModelSpec[]; errors: string[] } { return parseModelConfiguration(json); }
182
78
 
183
79
  export function findModel(id: string, catalog: readonly ModelSpec[] = CATALOG): ModelSpec | undefined {
184
80
  return catalog.find((m) => m.id === id);
@@ -187,7 +83,7 @@ export function findModel(id: string, catalog: readonly ModelSpec[] = CATALOG):
187
83
  /**
188
84
  * Resolve a typed pattern to one catalog entry.
189
85
  *
190
- * Order is exact id, then `provider/id`, then substring, then subsequence
86
+ * Order is `provider/id`, then exact id, then substring, then subsequence
191
87
  * ("fuzzy"). Each tier is tried only if the one above it matched nothing,
192
88
  * so a unique exact id cannot be displaced by a looser match. Several
193
89
  * hits at the winning tier is an error that lists the candidates --
@@ -208,12 +104,12 @@ export function findModelByPattern(
208
104
  return { ok: false, reason: "empty model pattern", candidates: [] };
209
105
  }
210
106
 
211
- const exactId = catalog.filter((m) => m.id.toLowerCase() === needle);
212
- if (exactId.length > 0) return reportPattern(pattern, exactId);
213
-
214
107
  const byRef = catalog.filter((m) => `${m.provider}/${m.id}`.toLowerCase() === needle);
215
108
  if (byRef.length > 0) return reportPattern(pattern, byRef);
216
109
 
110
+ const exactId = catalog.filter((m) => m.id.toLowerCase() === needle);
111
+ if (exactId.length > 0) return reportPattern(pattern, exactId);
112
+
217
113
  const substring = catalog.filter((m) => haystacks(m).some((h) => h.includes(needle)));
218
114
  if (substring.length > 0) return reportPattern(pattern, substring);
219
115
 
@@ -245,7 +141,7 @@ function reportPattern(pattern: string, matches: readonly ModelSpec[]): ModelPat
245
141
  if (matches.length === 0) {
246
142
  return { ok: false, reason: `unknown model: ${pattern}. Try --list-models`, candidates: [] };
247
143
  }
248
- const ids = matches.map((m) => m.id);
144
+ const ids = matches.map(modelKey);
249
145
  return {
250
146
  ok: false,
251
147
  reason:
@@ -302,11 +198,9 @@ export function selectModel(
302
198
  catalog: readonly ModelSpec[] = CATALOG,
303
199
  ): Selection {
304
200
  if (flags.model !== undefined && flags.model.length > 0) {
305
- const spec = findModel(flags.model, catalog);
306
- if (spec === undefined) {
307
- return { ok: false, reason: `unknown model: ${flags.model}. Try --list-models` };
308
- }
309
- return { ok: true, spec };
201
+ const candidates = flags.provider ? modelsOfProvider(flags.provider, catalog) : catalog;
202
+ const match = findModelByPattern(flags.model, candidates);
203
+ return match.ok ? { ok: true, spec: match.spec } : { ok: false, reason: `${match.reason}. Try --list-models` };
310
204
  }
311
205
 
312
206
  if (flags.provider !== undefined && flags.provider.length > 0) {
@@ -318,7 +212,9 @@ export function selectModel(
318
212
  }
319
213
 
320
214
  if (settings.defaultModel !== undefined) {
321
- const spec = findModel(settings.defaultModel, catalog);
215
+ const candidates = settings.defaultProvider ? catalog.filter(model => model.provider === settings.defaultProvider) : catalog;
216
+ const found = findModelByPattern(settings.defaultModel, candidates);
217
+ const spec = found.ok ? found.spec : undefined;
322
218
  if (spec !== undefined) return { ok: true, spec };
323
219
  // Not fatal: a settings file naming a model this build dropped should
324
220
  // degrade to the normal search, not refuse to start.
@@ -346,9 +242,13 @@ export function createProvider(
346
242
  config: ProviderConfig,
347
243
  apiKey: string,
348
244
  onRetry?: (attempt: number, delayMs: number, error: unknown) => void,
349
- opts?: { oauth?: boolean },
245
+ opts?: { oauth?: boolean; fetch?: typeof fetch; effects?: ResolveEffects },
350
246
  ): Provider {
351
247
  const oauth = opts?.oauth === true;
248
+ if (config.spec.driver === "pi" || hasProviderAccess(config.spec)) return new PiProvider(config, apiKey, {
249
+ oauth,
250
+ ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}),
251
+ });
352
252
  switch (config.spec.api) {
353
253
  case "anthropic-messages":
354
254
  return new AnthropicProvider(config, apiKey.length > 0 ? apiKey : undefined, {
@@ -359,6 +259,9 @@ export function createProvider(
359
259
  return new OpenAICompletionsProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}) });
360
260
  case "openai-responses":
361
261
  return new OpenAIResponsesProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}) });
262
+ case "azure-openai-responses": case "openai-codex-responses": case "google-generative-ai": case "google-vertex":
263
+ case "bedrock-converse-stream": case "mistral-conversations": case "pi-messages":
264
+ return new PiProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}) });
362
265
  default:
363
266
  return assertNever(config.spec.api, "createProvider");
364
267
  }
@@ -0,0 +1,114 @@
1
+ /** Pi's files → image → text precedence, with native-helper and OS-command fallbacks (MIT, 02eed88). */
2
+ import { fileURLToPath } from "node:url";
3
+ import { getNativeClipboard, type NativeClipboard } from "../vendor/pi-tui/native-platform.ts";
4
+ import { runClipboardCommand } from "../vendor/pi-rich/clipboard-command.ts";
5
+
6
+ export type ClipboardContent = { files: string[] } | { image: Uint8Array } | { text: string } | null;
7
+ type Run = (command: string, args: readonly string[], options?: { input?: string; timeoutMs?: number }) => Promise<Buffer | undefined>;
8
+ export interface ClipboardOptions {
9
+ platform?: NodeJS.Platform;
10
+ env?: NodeJS.ProcessEnv;
11
+ native?: NativeClipboard | null;
12
+ run?: Run;
13
+ }
14
+
15
+ /** Text is transported as data. Clipboard contents must never become PowerShell or shell source. */
16
+ export async function writeClipboard(text: string, options: ClipboardOptions = {}): Promise<boolean> {
17
+ const platform = options.platform ?? process.platform, env = options.env ?? process.env;
18
+ const native = options.native === undefined ? getNativeClipboard() : options.native, run = options.run ?? runClipboardCommand;
19
+ if (native?.setText) {
20
+ try { await native.setText(text); return true; } catch { /* Continue to the same platform's command transport. */ }
21
+ }
22
+ const windows = platform === "win32" || !!(env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]);
23
+ const commands: readonly (readonly [string, readonly string[]])[] = windows
24
+ ? [["powershell.exe", ["-NoProfile", "-STA", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; $t = [Console]::In.ReadToEnd(); if ($t.Length) { [System.Windows.Forms.Clipboard]::SetText($t) } else { [System.Windows.Forms.Clipboard]::Clear() }"]]]
25
+ : platform === "darwin" ? [["pbcopy", []]]
26
+ : env["TERMUX_VERSION"] ? [["termux-clipboard-set", []]]
27
+ : platform === "linux" ? [
28
+ ...(env["WAYLAND_DISPLAY"] || env["XDG_SESSION_TYPE"] === "wayland" ? [["wl-copy", ["--type", "text/plain;charset=utf-8"]] as const] : []),
29
+ ...(env["DISPLAY"] ? [["xclip", ["-selection", "clipboard"]] as const, ["xsel", ["--clipboard", "--input"]] as const] : []),
30
+ ] : [];
31
+ for (const [command, args] of commands) {
32
+ try { if (await run(command, args, { input: text, timeoutMs: 5000 }) !== undefined) return true; } catch { /* Try a same-platform backend. */ }
33
+ }
34
+ return false;
35
+ }
36
+
37
+ // Fixed programs only. Clipboard content is data, never interpolated into a shell or script.
38
+ const MAC_READ = `ObjC.import('AppKit');
39
+ const p = $.NSPasteboard.generalPasteboard;
40
+ const urls = p.readObjectsForClassesOptions([$.NSURL], $({NSPasteboardURLReadingFileURLsOnlyKey: true}));
41
+ const files = [];
42
+ if (urls) for (let i = 0; i < urls.count; i++) files.push(ObjC.unwrap(urls.objectAtIndex(i).path));
43
+ if (files.length) JSON.stringify({files});
44
+ else {
45
+ const png = p.dataForType('public.png');
46
+ const tiff = png ? null : p.dataForType('public.tiff');
47
+ const data = png || (tiff ? $.NSBitmapImageRep.imageRepWithData(tiff).representationUsingTypeProperties($.NSPNGFileType, $({})) : null);
48
+ if (data) JSON.stringify({image: ObjC.unwrap(data.base64EncodedStringWithOptions(0))});
49
+ else JSON.stringify({text: ObjC.unwrap(p.stringForType($.NSPasteboardTypeString)) || ''});
50
+ }`;
51
+ const WINDOWS_READ = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing;
52
+ $c = [System.Windows.Forms.Clipboard];
53
+ if ($c::ContainsFileDropList()) { @{files = @($c::GetFileDropList())} | ConvertTo-Json -Compress }
54
+ elseif ($c::ContainsImage()) { $i = $c::GetImage(); $s = New-Object System.IO.MemoryStream; try { $i.Save($s, [System.Drawing.Imaging.ImageFormat]::Png); @{image = [Convert]::ToBase64String($s.ToArray())} | ConvertTo-Json -Compress } finally { $i.Dispose(); $s.Dispose() } }
55
+ else { @{text = $c::GetText()} | ConvertTo-Json -Compress }`;
56
+
57
+ function decode(bytes: Buffer | undefined): ClipboardContent | undefined {
58
+ if (bytes === undefined) return undefined;
59
+ const value = JSON.parse(bytes.toString("utf8")) as { files?: unknown; image?: unknown; text?: unknown };
60
+ if (Array.isArray(value.files) && value.files.every(p => typeof p === "string") && value.files.length) return { files: value.files };
61
+ if (typeof value.image === "string" && value.image) return { image: Buffer.from(value.image, "base64") };
62
+ return typeof value.text === "string" && value.text ? { text: value.text } : null;
63
+ }
64
+
65
+ export async function readClipboard(options: ClipboardOptions = {}): Promise<ClipboardContent> {
66
+ const platform = options.platform ?? process.platform, env = options.env ?? process.env;
67
+ const run = options.run ?? runClipboardCommand;
68
+ const native = options.native === undefined ? getNativeClipboard() : options.native;
69
+ const files = await native?.getFilePaths?.();
70
+ if (files?.length) return { files };
71
+ if (platform === "darwin" && !native) {
72
+ const result = decode(await run("osascript", ["-l", "JavaScript", "-e", MAC_READ]));
73
+ if (result !== undefined) return result;
74
+ }
75
+ const wsl = !!(env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]);
76
+ if (platform === "win32" || wsl) {
77
+ const result = decode(await run("powershell.exe", ["-NoProfile", "-STA", "-Command", WINDOWS_READ], { timeoutMs: 5000 }));
78
+ if (result !== undefined) return result;
79
+ }
80
+ if (platform === "linux" && !env["TERMUX_VERSION"]) {
81
+ const wayland = !!env["WAYLAND_DISPLAY"] || env["XDG_SESSION_TYPE"] === "wayland";
82
+ const backends = [...(wayland ? ["wayland"] : []), ...(env["DISPLAY"] ? ["x11"] : [])];
83
+ for (const backend of backends) {
84
+ const read = (type: string) => backend === "wayland" ? run("wl-paste", ["--type", type, "--no-newline"]) : run("xclip", ["-selection", "clipboard", "-t", type, "-o"]);
85
+ const available = backend === "wayland" ? await run("wl-paste", ["--list-types"], { timeoutMs: 1000 }) : await read("TARGETS");
86
+ if (available === undefined) continue;
87
+ const types = available.toString("utf8").trim().split(/\r?\n/);
88
+ if (types.includes("text/uri-list")) {
89
+ const bytes = await read("text/uri-list");
90
+ if (bytes === undefined) throw new Error("Clipboard file transfer failed");
91
+ const paths = bytes.toString("utf8").split(/\r?\n/).filter(p => p.startsWith("file://")).map(p => fileURLToPath(p));
92
+ if (paths.length) return { files: paths };
93
+ }
94
+ const type = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"].find(t => types.includes(t));
95
+ if (type) {
96
+ const bytes = await read(type);
97
+ if (bytes === undefined) throw new Error("Clipboard image transfer failed");
98
+ return bytes.length ? { image: bytes } : null;
99
+ }
100
+ const textType = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain", "text"].find(t => types.includes(t));
101
+ if (!textType) return null; // An empty Wayland selection must not reveal stale X11 contents.
102
+ const bytes = await read(textType);
103
+ if (bytes === undefined) throw new Error("Clipboard text transfer failed");
104
+ return bytes.length ? { text: bytes.toString("utf8") } : null;
105
+ }
106
+ }
107
+ const image = await native?.getImage();
108
+ if (image?.length) return { image };
109
+ const text = await native?.getText();
110
+ if (text !== undefined) return text ? { text } : null;
111
+ const fallback = env["TERMUX_VERSION"] ? await run("termux-clipboard-get", []) : platform === "darwin" ? await run("pbpaste", []) : undefined;
112
+ if (fallback === undefined) throw new Error("Clipboard unavailable for this terminal session");
113
+ return fallback.length ? { text: fallback.toString("utf8") } : null;
114
+ }