@visual-z/xeno 0.1.4 → 0.2.1

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 (147) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +23 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +45 -18
  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-input.ts +94 -0
  21. package/src/apps/startup.ts +413 -295
  22. package/src/core/action-defaults.ts +306 -0
  23. package/src/core/attachment-input.ts +17 -0
  24. package/src/core/catalog-merge.ts +19 -0
  25. package/src/core/event-validation.ts +19 -2
  26. package/src/core/events.ts +45 -2
  27. package/src/core/export.ts +6 -2
  28. package/src/core/extension-flags.ts +14 -0
  29. package/src/core/images.ts +46 -0
  30. package/src/core/json-event.ts +2 -0
  31. package/src/core/keymap.ts +43 -119
  32. package/src/core/model-replay.ts +22 -0
  33. package/src/core/model-scope.ts +53 -0
  34. package/src/core/packages.ts +47 -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/tui/chat-viewport.ts +47 -0
  92. package/src/tui/editor.ts +62 -15
  93. package/src/tui/host.ts +50 -9
  94. package/src/tui/input.ts +8 -2
  95. package/src/tui/select.ts +24 -6
  96. package/src/tui/settings.ts +4 -0
  97. package/src/tui/system-theme.ts +700 -0
  98. package/src/tui/transcript.ts +63 -11
  99. package/src/tui/tree.ts +161 -95
  100. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  101. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  102. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  103. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  104. package/src/vendor/pi-rich/html.ts +51 -0
  105. package/src/vendor/pi-rich/image-convert.ts +49 -0
  106. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  107. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  108. package/src/vendor/pi-rich/image-resize.ts +123 -0
  109. package/src/vendor/pi-rich/mime.ts +116 -0
  110. package/src/vendor/pi-rich/photon.ts +139 -0
  111. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  112. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  113. package/src/vendor/pi-tui/VENDOR.md +7 -5
  114. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  115. package/src/vendor/pi-tui/colors.ts +367 -0
  116. package/src/vendor/pi-tui/components/box.ts +8 -9
  117. package/src/vendor/pi-tui/components/editor.ts +4 -2
  118. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  119. package/src/vendor/pi-tui/index.ts +31 -1
  120. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  121. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  122. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  123. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  127. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  128. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  132. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  133. package/src/vendor/pi-tui/native/napi.h +91 -0
  134. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  135. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  139. package/src/vendor/pi-tui/native-platform.ts +2 -0
  140. package/src/vendor/pi-tui/oklab.ts +233 -0
  141. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  142. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  143. package/src/vendor/pi-tui/terminal.ts +16 -9
  144. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  145. package/src/vendor/pi-tui/tui.ts +112 -75
  146. package/src/vendor/pi-tui/utils.ts +89 -49
  147. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -15,11 +15,13 @@ import { expandTemplate, type PromptTemplate } from "../core/prompts.ts";
15
15
  import { skillInvocationMessage, type Skill } from "../core/skills.ts";
16
16
 
17
17
  export type Command =
18
+ | { kind: "extension"; name: string; args: string }
18
19
  | { kind: "prompt"; text: string }
19
20
  | { kind: "quit" }
20
21
  | { kind: "usage" }
21
22
  | { kind: "state" }
22
23
  | { kind: "model"; pattern?: string }
24
+ | { kind: "scoped-models" }
23
25
  | { kind: "login"; provider?: string }
24
26
  | { kind: "logout"; provider?: string }
25
27
  | { kind: "back"; arg?: string; mode: RewindMode; confirm: boolean }
@@ -29,13 +31,19 @@ export type Command =
29
31
  | { kind: "name"; name: string }
30
32
  | { kind: "session" }
31
33
  | { kind: "help" }
32
- | { kind: "trust"; target: "here" | "parent" | "never" }
34
+ | { kind: "trust"; target?: "here" | "parent" | "never" | "once" }
35
+ | { kind: "reload" }
36
+ | { kind: "config" }
33
37
  | { kind: "models-refresh" }
34
38
  | { kind: "settings"; field?: string; value?: string }
35
39
  | { kind: "hotkeys" }
36
40
  | { kind: "thinking"; level?: string }
37
41
  | { kind: "export"; file?: string }
42
+ | { kind: "import"; file?: string }
43
+ | { kind: "debug"; file?: string }
44
+ | { kind: "changelog" }
38
45
  | { kind: "copy" }
46
+ | { kind: "attachments" }
39
47
  | { kind: "fork"; arg?: string }
40
48
  | { kind: "clone" }
41
49
  | { kind: "unknown"; input: string };
@@ -43,8 +51,9 @@ export type Command =
43
51
  /** One catalog owns discoverability, parsing and busy-turn policy. */
44
52
  export const COMMANDS = [
45
53
  { name: "model", description: "Choose a model", argumentHint: "[id]", busy: "idle" },
54
+ { name: "scoped-models", description: "Enable and order models for cycling", busy: "idle" },
46
55
  { name: "thinking", description: "Set reasoning effort", argumentHint: "[level]", busy: "idle" },
47
- { name: "settings", description: "Change theme, thinking and status bar", argumentHint: "[key value]", busy: "idle" },
56
+ { name: "settings", description: "Configure appearance, input and model behavior", argumentHint: "[key value]", busy: "idle" },
48
57
  { name: "login", description: "Sign in or store an API key", argumentHint: "[provider]", busy: "idle" },
49
58
  { name: "logout", description: "Remove a stored credential", argumentHint: "[provider]", busy: "idle" },
50
59
  { name: "new", description: "Start a new session", busy: "idle" },
@@ -58,9 +67,15 @@ export const COMMANDS = [
58
67
  { name: "usage", description: "Show token usage and costs", busy: "run" },
59
68
  { name: "state", description: "Show log and handoff state", busy: "run" },
60
69
  { name: "export", description: "Export HTML or JSONL", argumentHint: "[file]", busy: "idle" },
70
+ { name: "import", description: "Import a portable JSONL session", argumentHint: "<file>", busy: "idle" },
71
+ { name: "debug", description: "Write local terminal diagnostics", argumentHint: "[file]", busy: "run" },
72
+ { name: "changelog", description: "Show local release notes", busy: "run" },
61
73
  { name: "copy", description: "Copy the last assistant reply", busy: "run" },
74
+ { name: "attachments", description: "Remove images from the current draft", busy: "run" },
62
75
  { name: "models", description: "Refresh the model catalog", argumentHint: "refresh", busy: "idle" },
63
76
  { name: "trust", description: "Set workspace configuration trust", argumentHint: "[here|parent|never]", busy: "idle" },
77
+ { name: "reload", description: "Reload settings, skills, prompts, themes and models", busy: "idle" },
78
+ { name: "config", description: "Configure resource sources and enablement", busy: "idle" },
64
79
  { name: "hotkeys", description: "Show active keybindings", busy: "run" },
65
80
  { name: "help", description: "Show commands and input help", busy: "run" },
66
81
  { name: "quit", description: "Exit xeno", busy: "run" },
@@ -79,6 +94,7 @@ const BUILTIN_SET = new Set<string>(BUILTIN_COMMAND_NAMES);
79
94
  export interface SlashCatalog {
80
95
  readonly prompts: ReadonlyMap<string, PromptTemplate>;
81
96
  readonly skills: ReadonlyMap<string, Skill>;
97
+ readonly skillCommands?: boolean;
82
98
  }
83
99
 
84
100
  export function resolveSlashCatalog(
@@ -130,6 +146,10 @@ export function parseCommand(input: string, catalog?: SlashCatalog): Command {
130
146
  if (!commandDefinition(text)) return resolveRegistered(token, arg, text, rest.length, catalog);
131
147
 
132
148
  switch (token) {
149
+ case "/reload": return { kind: "reload" };
150
+ case "/config": return { kind: "config" };
151
+ case "/attachments": return { kind: "attachments" };
152
+ case "/scoped-models": return { kind: "scoped-models" };
133
153
  case "/quit":
134
154
  case "/exit":
135
155
  return { kind: "quit" };
@@ -178,8 +198,14 @@ export function parseCommand(input: string, catalog?: SlashCatalog): Command {
178
198
  return { kind: "hotkeys" };
179
199
  case "/thinking":
180
200
  return arg.length > 0 ? { kind: "thinking", level: arg } : { kind: "thinking" };
201
+ case "/changelog": return { kind: "changelog" };
181
202
  case "/export":
182
- return arg.length > 0 ? { kind: "export", file: arg } : { kind: "export" };
203
+ case "/import":
204
+ case "/debug": {
205
+ const kind = token.slice(1) as "export" | "import" | "debug";
206
+ const path = text.slice(head!.length).trim().replace(/^(?:"(.*)"|'(.*)')$/, (_m, double: string | undefined, single: string | undefined) => double ?? single ?? "");
207
+ return path ? { kind, file: path } : { kind };
208
+ }
183
209
  case "/copy":
184
210
  return { kind: "copy" };
185
211
  case "/fork":
@@ -187,7 +213,9 @@ export function parseCommand(input: string, catalog?: SlashCatalog): Command {
187
213
  case "/clone":
188
214
  return { kind: "clone" };
189
215
  case "/trust": {
216
+ if (!rest.length) return { kind: "trust" };
190
217
  const word = (rest[0] ?? "here").toLowerCase();
218
+ if (word === "once") return { kind: "trust", target: "once" };
191
219
  if (rest.length === 0 || word === "here" || word === "yes") {
192
220
  return { kind: "trust", target: "here" };
193
221
  }
@@ -208,7 +236,7 @@ function resolveRegistered(
208
236
  catalog: SlashCatalog | undefined,
209
237
  ): Command {
210
238
  if (catalog !== undefined) {
211
- if (token.startsWith("/skill:")) {
239
+ if (token.startsWith("/skill:") && catalog.skillCommands !== false) {
212
240
  const name = token.slice("/skill:".length);
213
241
  const skill = catalog.skills.get(name);
214
242
  if (skill !== undefined) {
@@ -2,16 +2,18 @@ import { COMMANDS } from "./commands.ts";
2
2
  import type { App } from "./startup.ts";
3
3
  import { WorkspaceAutocomplete } from "../tui/autocomplete.ts";
4
4
  import { SUBSCRIPTION_OPTIONS } from "../runtime/oauth.ts";
5
+ import { modelKey } from "../core/session-position.ts";
6
+ import { supportedThinkingLevels } from "../core/thinking.ts";
7
+ import { SETTINGS_KEYS, DISPLAY_SETTINGS } from "../core/settings.ts";
5
8
 
6
9
  export function interactiveAutocomplete(app: App, paths: (refresh?: boolean) => Promise<readonly string[]>): WorkspaceAutocomplete {
7
10
  const providers = [...new Set([...app.catalog.map((m) => m.provider), ...SUBSCRIPTION_OPTIONS.map((p) => p.id)])];
8
11
  const values: Record<string, () => string[]> = {
9
- model: () => app.catalog.map((m) => m.id),
10
- thinking: () => ["low", "medium", "high", "xhigh", "max"].filter((level) =>
11
- app.providerConfig.spec.thinkingLevelMap?.[level as "low"] !== null),
12
+ model: () => app.catalog.map(modelKey),
13
+ thinking: () => [...supportedThinkingLevels(app.providerConfig.spec)],
12
14
  login: () => providers, logout: () => providers,
13
- settings: () => ["theme", "thinking", "statusBar"],
14
- models: () => ["refresh"], trust: () => ["here", "parent", "never"],
15
+ settings: () => [...new Set([...SETTINGS_KEYS, ...DISPLAY_SETTINGS.map(item => item.id)])],
16
+ models: () => ["refresh"], trust: () => ["once", "here", "parent", "never"],
15
17
  };
16
18
  const commands = COMMANDS.map((definition) => ({
17
19
  ...definition,
@@ -21,8 +23,9 @@ export function interactiveAutocomplete(app: App, paths: (refresh?: boolean) =>
21
23
  },
22
24
  }));
23
25
  const registered = [
26
+ ...[...app.extensions?.commands ?? []].map(([name, command]) => ({ name, ...command })),
24
27
  ...[...(app.slash?.prompts.values() ?? [])].map((p) => ({ name: p.name, description: p.description })),
25
- ...[...(app.slash?.skills.values() ?? [])].map((s) => ({ name: `skill:${s.name}`, description: s.description })),
28
+ ...(app.slash?.skillCommands === false ? [] : [...(app.slash?.skills.values() ?? [])].map((s) => ({ name: `skill:${s.name}`, description: s.description }))),
26
29
  ];
27
30
  return new WorkspaceAutocomplete([...commands, ...registered], app.workspace, paths);
28
31
  }
@@ -0,0 +1,24 @@
1
+ /** Offline export is a local storage operation and never constructs an agent or loads extensions. */
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { toHtml } from "../core/export.ts";
6
+ import { readCursor } from "../store/log.ts";
7
+ import { readNodeHistory } from "../store/restore.ts";
8
+ import { importSessionArchive } from "../store/session-archive.ts";
9
+ import { findSessionByPrefix } from "../store/sessions.ts";
10
+
11
+ export async function exportHtmlFile(input: string, output: string | undefined, workspace: string): Promise<string> {
12
+ const source = resolve(workspace, input), temporary = await mkdtemp(join(tmpdir(), "xeno-export-"));
13
+ try {
14
+ let dir: string;
15
+ if (await stat(source).then(info => info.isFile(), () => false)) dir = await importSessionArchive(source, temporary, workspace);
16
+ else { const found = await findSessionByPrefix(workspace, input); if (!found.ok) throw new Error(found.reason); dir = found.session.path; }
17
+ const cursor = await readCursor(dir); if (!cursor) throw new Error("Session has no cursor");
18
+ const history = await readNodeHistory(dir, cursor.node); if (!history.ok) throw new Error(history.error);
19
+ const destination = resolve(workspace, output || basename(input).replace(/\.jsonl$/i, "") + ".html");
20
+ if (source === destination) throw new Error("Export destination must differ from its source");
21
+ await writeFile(destination, toHtml(history.nodes.flatMap(node => node.events), { title: cursor.name ?? basename(input), exportedAt: new Date().toISOString() }), { mode: 0o600 });
22
+ return destination;
23
+ } finally { await rm(temporary, { recursive: true, force: true }); }
24
+ }
@@ -0,0 +1,330 @@
1
+ /** Pi extension UI ownership, bound to a single resource generation (D19). */
2
+ import type { ExtensionRegistry, ExtensionToolResult, ExtensionToolRenderContext } from "../runtime/extensions.ts";
3
+ import type { ExtensionUI, ExtensionComponent, ExtensionTheme, WidgetFactory, FooterFactory, EditorFactory, AutocompleteFactory, InputHandler, CustomOptions, DialogOptions } from "../runtime/extension-ui.ts";
4
+ import type { Theme } from "../core/theme.ts";
5
+ import { builtinTheme } from "../core/theme.ts";
6
+ import type { ResourceIndex } from "../store/resource-index.ts";
7
+ import { themeResourcePath } from "../store/resource-index.ts";
8
+ import { discoverThemes, resolveTheme } from "../store/resources.ts";
9
+ import { Container, Lines, PiText, identityEditorTheme, type InteractiveScreen } from "../tui/host.ts";
10
+ import type { Editor } from "../tui/editor.ts";
11
+ import { SelectList } from "../tui/select.ts";
12
+ import { keyMatches } from "../tui/input.ts";
13
+ import { Editor as PiEditor } from "../vendor/pi-tui/components/editor.ts";
14
+ import { Input } from "../vendor/pi-tui/components/input.ts";
15
+ import type { Component, OverlayHandle } from "../vendor/pi-tui/tui.ts";
16
+ import type { KeybindingsManager } from "../vendor/pi-tui/keybindings.ts";
17
+ import type { AutocompleteProvider } from "../vendor/pi-tui/autocomplete.ts";
18
+
19
+ interface Host {
20
+ screen(): InteractiveScreen; editor: Editor; theme(): Theme; colored(): boolean;
21
+ bindings(): KeybindingsManager; branch(): string | null;
22
+ redraw(): void; refreshTranscript(): void; input(data: string): void; notify: ExtensionUI["notify"];
23
+ setTheme(theme: Theme): void; expanded(): boolean; expand(value: boolean): void;
24
+ cwd(): string; showImages(): boolean; outputPad(): number; saveTheme(name: string): void;
25
+ }
26
+ type Slot = { factory: WidgetFactory; component: OwnedComponent; below: boolean };
27
+ type OwnedComponent = Component & { dispose(): void; focused?: boolean };
28
+ type Dialog = { component?: OwnedComponent; raw?: ExtensionComponent; handle?: OverlayHandle; close(value?: unknown): void };
29
+
30
+ export class ExtensionUiController {
31
+ readonly header = new Container();
32
+ readonly above = new Container();
33
+ readonly below = new Container();
34
+ readonly partials = new Container();
35
+ private partialComponents = new Map<string, OwnedComponent>();
36
+ private toolStates = new Map<string, Record<string, unknown>>();
37
+ private footer: OwnedComponent | undefined;
38
+ private headerSlot: Slot | undefined;
39
+ private footerFactory: FooterFactory | undefined;
40
+ private widgets = new Map<string, Slot>();
41
+ private statuses = new Map<string, string>();
42
+ private listeners = new Set<InputHandler>();
43
+ private branchListeners = new Set<() => void>();
44
+ private unlisten: (() => void) | undefined;
45
+ private unbind: (() => void) | undefined;
46
+ private unwatchTools: (() => void) | undefined;
47
+ private footerSubscriptions: (() => void)[] = [];
48
+ private editorFactory: EditorFactory | undefined;
49
+ private autocomplete: AutocompleteFactory[] = [];
50
+ private baseCompletion: AutocompleteProvider | undefined;
51
+ private themeCache = new Map<string, { theme: ExtensionTheme; path: string | undefined }>();
52
+ private registry: ExtensionRegistry | undefined;
53
+ private reported = new Set<string>();
54
+ private hasTitle = false;
55
+ private generation = 0;
56
+ private api: ExtensionUI;
57
+ private dialog: Dialog | undefined;
58
+ private pendingDialogs = new Set<() => void>();
59
+ private tail: Promise<unknown> = Promise.resolve();
60
+ private workingMessage: string | undefined;
61
+ private workingVisible = true;
62
+ private frames: string[] | undefined;
63
+ private frame = 0;
64
+ private interval = 80;
65
+ private timer: ReturnType<typeof setInterval> | undefined;
66
+ hiddenThinkingLabel: string | undefined;
67
+ private readonly host: Host;
68
+ constructor(host: Host) { this.host = host; this.host.editor.onComponentError = error => { this.editorFactory = undefined; this.report(error); }; this.api = this.createApi(); this.rebind(); }
69
+ get context(): ExtensionUI { return this.api; }
70
+ get modal(): Component | undefined { return this.dialog?.handle ? undefined : this.dialog?.component; }
71
+ get overlayFocused(): boolean { return this.dialog?.handle?.isFocused() ?? false; }
72
+ get customFooter(): Component | undefined { return this.footer; }
73
+ get status(): string { return [...this.statuses.values()].join(" · "); }
74
+ private report(error: unknown): void { const message = String(error); if (this.reported.has(message)) return; this.reported.add(message); this.host.notify(`Extension UI: ${message}`, "error"); }
75
+ private attempt<T>(fn: () => T): T | undefined { try { return fn(); } catch (error) { this.report(error); return undefined; } }
76
+
77
+ /** Errors are isolated at the component boundary, including errors thrown only during render. */
78
+ own(component: ExtensionComponent, fallback?: Component): OwnedComponent {
79
+ let failed = false, disposed = false;
80
+ const fail = (error: unknown) => { const report = !failed; failed = true; if (report) this.report(error); };
81
+ return {
82
+ render: width => { if (!failed) try { return component.render(width); } catch (error) { fail(error); } return fallback?.render(width) ?? []; },
83
+ invalidate: () => { if (!failed) try { component.invalidate?.(); } catch (error) { fail(error); } },
84
+ handleInput: data => { if (!failed) try { component.handleInput?.(data); } catch (error) { fail(error); } },
85
+ get focused() { return component.focused ?? false; }, set focused(value: boolean) { component.focused = value; },
86
+ dispose: () => { if (disposed) return; disposed = true; this.attempt(() => component.dispose?.()); },
87
+ };
88
+ }
89
+
90
+ async bind(registry: ExtensionRegistry | undefined, resources: ResourceIndex | undefined): Promise<void> {
91
+ if (registry === this.registry) return;
92
+ this.clear(); this.registry = registry; this.api = this.createApi();
93
+ this.unbind = registry?.onDispose(() => { this.clear(); this.registry = undefined; this.host.refreshTranscript(); });
94
+ this.unwatchTools = registry?.onToolUpdate((name, id, input, result) => {
95
+ const tool = registry.tools.get(name); if (!tool?.renderResult) return;
96
+ this.partialComponents.get(id)?.dispose();
97
+ this.partialComponents.set(id, this.renderTool(name, input, { text: result.content.filter(c => c.type === "text").map(c => c.text).join("\n"), ok: result.isError !== true, data: result.details }, this.host.expanded(), new Lines(() => []), new PiText(result.content.filter(c => c.type === "text").map(c => c.text).join("\n"), 0, 0), id, result));
98
+ this.partials.clear(); for (const component of this.partialComponents.values()) this.partials.addChild(component);
99
+ this.host.redraw();
100
+ });
101
+ const generation = this.generation, cache = new Map<string, { theme: ExtensionTheme; path: string | undefined }>();
102
+ for (const name of await discoverThemes(resources)) {
103
+ const path = resources ? themeResourcePath(resources, name) : undefined, errors: string[] = [];
104
+ const theme = await resolveTheme(path ?? name, errors);
105
+ if (!errors.length) cache.set(name, { theme: this.theme(theme), path });
106
+ }
107
+ if (generation === this.generation) this.themeCache = cache;
108
+ this.host.refreshTranscript();
109
+ }
110
+ private theme(theme = this.host.theme()): ExtensionTheme {
111
+ const ansi = (color: string) => this.host.colored() && theme.colors[color] ? `\x1b[${theme.colors[color]}m` : "";
112
+ const wrap = (open: string, text: string, close = "\x1b[0m") => open ? open + text + close : text;
113
+ const attr = (code: number, end: number) => (text: string) => wrap(this.host.colored() ? `\x1b[${code}m` : "", text, `\x1b[${end}m`);
114
+ return { ...theme, fg: (color, text) => wrap(ansi(color), text, "\x1b[39m"), bg: (color, text) => wrap(ansi(color), text, "\x1b[49m"),
115
+ bold: attr(1, 22), dim: attr(2, 22), italic: attr(3, 23), underline: attr(4, 24), inverse: attr(7, 27), strikethrough: attr(9, 29), getFgAnsi: ansi, getBgAnsi: ansi,
116
+ getColorMode: () => "truecolor", getThinkingBorderColor: level => text => wrap(ansi("thinking" + (level[0]?.toUpperCase() ?? "O") + level.slice(1)), text, "\x1b[39m"),
117
+ getBashModeBorderColor: () => text => wrap(ansi("bashMode"), text, "\x1b[39m") };
118
+ }
119
+
120
+ private createApi(): ExtensionUI {
121
+ const owner = this, generation = this.generation;
122
+ const api: ExtensionUI = {
123
+ notify: this.host.notify,
124
+ select: (title, choices, opts) => this.choose(title, choices, opts),
125
+ confirm: async (title, message, opts) => (await this.choose(`${title}\n${message}`, ["Yes", "No"], opts)) === "Yes",
126
+ input: (title, placeholder, opts) => this.textDialog(title, placeholder ?? "", false, opts),
127
+ editor: (title, prefill) => this.textDialog(title, prefill ?? "", true),
128
+ custom: (factory, opts) => this.custom(factory, opts),
129
+ onTerminalInput: handler => { this.listeners.add(handler); return () => this.listeners.delete(handler); },
130
+ setStatus: (key, text) => { if (text === undefined) this.statuses.delete(key); else this.statuses.set(key, text); this.host.redraw(); },
131
+ setWidget: (key, content, opts) => {
132
+ this.widgets.get(key)?.component.dispose(); this.widgets.delete(key);
133
+ if (content !== undefined) {
134
+ const factory: WidgetFactory = typeof content === "function" ? content : () => new PiText(content.join("\n"), 0, 0);
135
+ const component = this.attempt(() => factory(this.host.screen(), this.theme()));
136
+ if (component) this.widgets.set(key, { factory, component: this.own(component), below: opts?.placement === "belowEditor" });
137
+ }
138
+ this.mountWidgets(); this.host.redraw();
139
+ },
140
+ setHeader: factory => {
141
+ this.headerSlot?.component.dispose(); this.headerSlot = undefined; this.header.clear();
142
+ const component = factory && this.attempt(() => factory(this.host.screen(), this.theme()));
143
+ if (component) { this.headerSlot = { factory: factory!, component: this.own(component), below: false }; this.header.addChild(this.headerSlot.component); }
144
+ this.host.redraw();
145
+ },
146
+ setFooter: factory => {
147
+ this.footer?.dispose(); this.footer = undefined; this.footerFactory = factory;
148
+ for (const unsubscribe of this.footerSubscriptions.splice(0)) unsubscribe();
149
+ const component = factory && this.attempt(() => factory(this.host.screen(), this.theme(), {
150
+ getGitBranch: () => this.host.branch() ?? undefined, getExtensionStatuses: () => new Map(this.statuses),
151
+ onBranchChange: fn => { this.branchListeners.add(fn); const off = () => { this.branchListeners.delete(fn); }; this.footerSubscriptions.push(off); return off; },
152
+ }));
153
+ if (component) this.footer = this.own(component);
154
+ this.host.redraw();
155
+ },
156
+ setTitle: title => { this.hasTitle = true; this.host.screen().terminal.setTitle(title); },
157
+ setWorkingMessage: message => { this.workingMessage = message; this.host.redraw(); },
158
+ setWorkingVisible: visible => { this.workingVisible = visible; this.host.redraw(); },
159
+ setWorkingIndicator: options => { this.frames = options?.frames; this.interval = Math.max(16, options?.intervalMs ?? 80); this.frame = 0; clearInterval(this.timer); this.timer = undefined; this.host.redraw(); },
160
+ setHiddenThinkingLabel: label => { this.hiddenThinkingLabel = label; this.host.refreshTranscript(); this.host.redraw(); },
161
+ getEditorText: () => this.host.editor.state.text,
162
+ setEditorText: text => { this.host.editor.applyExternal(text); this.host.redraw(); },
163
+ pasteToEditor: text => { this.host.editor.acceptInput(`\x1b[200~${text}\x1b[201~`); this.host.redraw(); },
164
+ addAutocompleteProvider: factory => { this.autocomplete.push(factory); this.refreshCompletion(); },
165
+ setEditorComponent: factory => {
166
+ const component = factory && this.attempt(() => factory(this.host.screen(), identityEditorTheme(), this.host.bindings()));
167
+ if (factory && !component) return;
168
+ this.editorFactory = factory; this.host.editor.setComponent(component); this.host.redraw();
169
+ },
170
+ getEditorComponent: () => this.editorFactory,
171
+ get theme() { return owner.theme(); },
172
+ getAllThemes: () => [...this.themeCache].map(([name, value]) => ({ name, path: value.path })),
173
+ getTheme: name => this.themeCache.get(name)?.theme,
174
+ setTheme: name => {
175
+ const theme = typeof name === "string" ? this.themeCache.get(name)?.theme : name;
176
+ if (!theme || typeof theme.colors !== "object") return { success: false, error: `Unknown theme: ${String(name)}` };
177
+ this.host.setTheme(theme); if (typeof name === "string") this.host.saveTheme(name); return { success: true };
178
+ },
179
+ getToolsExpanded: () => this.host.expanded(), setToolsExpanded: value => { this.host.expand(value); this.host.redraw(); },
180
+ };
181
+ // An asynchronous extension from a disposed session must not mutate its successor's UI.
182
+ return new Proxy(api, { get(target, key, receiver) {
183
+ const value: unknown = Reflect.get(target, key, receiver);
184
+ return typeof value === "function" ? (...args: unknown[]) => generation === owner.generation ? Reflect.apply(value, target, args) : undefined : value;
185
+ } });
186
+ }
187
+
188
+ private mountWidgets(): void {
189
+ this.above.clear(); this.below.clear();
190
+ for (const slot of this.widgets.values()) (slot.below ? this.below : this.above).addChild(slot.component);
191
+ }
192
+ setCompletion(provider: AutocompleteProvider): void { this.baseCompletion = provider; this.refreshCompletion(); }
193
+ private refreshCompletion(): void {
194
+ if (!this.baseCompletion) return;
195
+ let provider: AutocompleteProvider = this.baseCompletion;
196
+ for (const factory of this.autocomplete) { const base: AutocompleteProvider = provider; provider = this.attempt(() => factory(base)) ?? base; }
197
+ this.host.editor.setAutocompleteProvider(provider);
198
+ }
199
+ filterInput(data: string): string | undefined {
200
+ for (const listener of this.listeners) { const value = data, result = this.attempt(() => listener(value)); if (result?.consume) return undefined; data = result?.data ?? data; }
201
+ return data;
202
+ }
203
+ acceptInput(data: string): boolean {
204
+ const dialog = this.dialog; if (!dialog || dialog.handle && !dialog.handle.isFocused()) return false;
205
+ if (keyMatches(data, "escape") || keyMatches(data, "ctrl+c")) dialog.close();
206
+ else this.attempt(() => dialog.raw?.handleInput?.(data));
207
+ this.host.redraw(); return true;
208
+ }
209
+ private choose(title: string, choices: string[], options?: DialogOptions): Promise<string | undefined> {
210
+ return this.custom<string | undefined>((_tui, _theme, _bindings, done) => {
211
+ const list = new SelectList(choices.map(value => ({ label: value, value })), { title, search: true });
212
+ return { render: width => list.render(width), invalidate: () => list.invalidate(), handleInput: data => { const state = list.acceptInput(data); if (state.confirmed) done(state.confirmed.value); else if (state.cancelled) done(undefined); } };
213
+ }, options);
214
+ }
215
+ private textDialog(title: string, value: string, multiline: boolean, options?: DialogOptions): Promise<string | undefined> {
216
+ return this.custom<string | undefined>((tui, _theme, _bindings, done) => {
217
+ const editor = multiline ? new PiEditor(tui, identityEditorTheme()) : new Input();
218
+ if (editor instanceof PiEditor) editor.setText(value);
219
+ editor.onSubmit = text => done(text); editor.focused = true;
220
+ return { render: width => [title,
221
+ ...(editor instanceof Input && !editor.getValue() && value ? [value] : []), ...editor.render(width)], invalidate: () => editor.invalidate(), handleInput: data => editor.handleInput(data) };
222
+ }, options);
223
+ }
224
+ private custom<T>(factory: Parameters<ExtensionUI["custom"]>[0], options?: CustomOptions): Promise<T> {
225
+ const generation = this.generation;
226
+ let resolve!: (result: T) => void;
227
+ const result = new Promise<T>(done => { resolve = done; });
228
+ let closed = false, timeout: ReturnType<typeof setTimeout> | undefined, tick: ReturnType<typeof setInterval> | undefined;
229
+ const cancel = () => dialog.close();
230
+ const dialog: Dialog = { close: value => {
231
+ if (closed) return; closed = true;
232
+ clearTimeout(timeout); clearInterval(tick); options?.signal?.removeEventListener("abort", cancel); this.pendingDialogs.delete(cancel);
233
+ dialog.handle?.hide(); dialog.component?.dispose();
234
+ if (this.dialog === dialog) this.dialog = undefined;
235
+ resolve(value as T); this.host.redraw();
236
+ } };
237
+ this.pendingDialogs.add(cancel);
238
+ options?.signal?.addEventListener("abort", cancel, { once: true });
239
+ if (options?.signal?.aborted) cancel();
240
+ const before = this.tail; this.tail = before.then(() => result);
241
+ void before.then(async () => {
242
+ if (closed || generation !== this.generation) return cancel();
243
+ this.dialog = dialog;
244
+ dialog.component = this.own(new PiText("Loading…", 0, 0)); this.host.redraw();
245
+ if (options?.timeout !== undefined) { timeout = setTimeout(cancel, Math.max(0, options.timeout)); tick = setInterval(() => this.host.redraw(), 200); }
246
+ try {
247
+ const component = await factory(this.host.screen(), this.theme(), this.host.bindings(), value => dialog.close(value));
248
+ if (closed || generation !== this.generation) { this.attempt(() => component.dispose?.()); return; }
249
+ dialog.component?.dispose(); dialog.raw = component;
250
+ const owner = this.own(component);
251
+ // Pi receives raw terminal input through this wrapper; the app loop remains its only dispatcher.
252
+ const deadline = options?.timeout === undefined ? undefined : Date.now() + options.timeout;
253
+ dialog.component = { ...owner, render: width => [...(deadline === undefined ? [] : [`${Math.max(0, Math.ceil((deadline - Date.now()) / 1000))}s remaining`]), ...owner.render(width)],
254
+ get focused() { return component.focused ?? false; }, set focused(value: boolean) { component.focused = value; }, handleInput: data => this.host.input(data) };
255
+ if (options?.overlay) {
256
+ dialog.handle = this.host.screen().showOverlay(dialog.component!, typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions);
257
+ options.onHandle?.({ ...dialog.handle, hide: cancel });
258
+ }
259
+ this.host.redraw();
260
+ } catch (error) { this.report(error); cancel(); }
261
+ });
262
+ return result;
263
+ }
264
+ working(active: boolean, fallback: string): string | undefined {
265
+ if (!active || !this.workingVisible) { clearInterval(this.timer); this.timer = undefined; return undefined; }
266
+ const frames = this.frames ?? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
267
+ if (frames.length > 1 && !this.timer) { this.timer = setInterval(() => { this.frame++; this.host.redraw(); }, this.interval); this.timer.unref(); }
268
+ return `${frames.length ? frames[this.frame % frames.length] + " " : ""}${this.workingMessage ?? fallback} · Esc to interrupt`;
269
+ }
270
+ /** Screen replacement cannot leave components pointing at a stopped Pi host. */
271
+ rebind(screenChanged = true): void {
272
+ if (screenChanged) this.cancelDialogs(); this.unlisten?.();
273
+ this.unlisten = this.host.screen().addInputListener(data => { const filtered = this.filterInput(data); return filtered === undefined ? { consume: true } : { data: filtered }; });
274
+ for (const [key, slot] of [...this.widgets]) this.api.setWidget(key, slot.factory, { placement: slot.below ? "belowEditor" : "aboveEditor" });
275
+ if (this.headerSlot) this.api.setHeader(this.headerSlot.factory);
276
+ if (this.footerFactory) this.api.setFooter(this.footerFactory);
277
+ if (this.editorFactory) this.api.setEditorComponent(this.editorFactory);
278
+ this.branchChanged();
279
+ }
280
+ branchChanged(): void { for (const listener of this.branchListeners) this.attempt(listener); }
281
+ clearPartials(): void { for (const component of this.partialComponents.values()) component.dispose(); this.partialComponents.clear(); this.partials.clear(); }
282
+ cancelDialogs(): void { for (const cancel of [...this.pendingDialogs]) cancel(); }
283
+ private clear(): void {
284
+ this.generation++; this.cancelDialogs(); clearInterval(this.timer); this.timer = undefined;
285
+ if (this.hasTitle) { this.host.screen().terminal.setTitle("xeno"); this.hasTitle = false; }
286
+ this.unbind?.(); this.unbind = undefined; this.unwatchTools?.(); this.unwatchTools = undefined; this.clearPartials();
287
+ this.toolStates.clear();
288
+ this.reported.clear();
289
+ for (const slot of this.widgets.values()) slot.component.dispose(); this.widgets.clear(); this.mountWidgets();
290
+ this.headerSlot?.component.dispose(); this.headerSlot = undefined; this.header.clear();
291
+ this.footer?.dispose(); this.footer = undefined; this.footerFactory = undefined;
292
+ for (const unsubscribe of this.footerSubscriptions.splice(0)) unsubscribe();
293
+ this.editorFactory = undefined; this.attempt(() => this.host.editor.setComponent(undefined));
294
+ this.autocomplete = []; this.refreshCompletion(); this.statuses.clear(); this.listeners.clear(); this.branchListeners.clear();
295
+ this.workingMessage = undefined; this.workingVisible = true; this.frames = undefined; this.hiddenThinkingLabel = undefined;
296
+ this.themeCache = new Map(["dark", "light"].map(name => [name, { theme: this.theme(builtinTheme(name === "dark" ? "dark" : "light")), path: undefined }]));
297
+ }
298
+ dispose(): void { this.clear(); this.unlisten?.(); this.unlisten = undefined; }
299
+ transformMarkdown(text: string, width: number, streaming: boolean, messageType = "assistant"): string {
300
+ for (const transform of this.registry?.markdown ?? []) { const input = text; text = this.attempt(() => transform(input, { availableWidth: width, isStreaming: streaming, messageType })) ?? input; }
301
+ return text;
302
+ }
303
+ renderMessage(type: string, text: string, data: unknown, expanded: boolean, fallback: Component, metadata: { id: string; entry: boolean; timestamp: string }): Component {
304
+ const render = (metadata.entry ? this.registry?.entryRenderers : this.registry?.renderers)?.get(type);
305
+ const message = metadata.entry ? { type: "custom", id: metadata.id, timestamp: metadata.timestamp, customType: type, data }
306
+ : { role: "custom", customType: type, content: text, details: data, display: true, timestamp: Date.parse(metadata.timestamp) };
307
+ const component = render && this.attempt(() => render(message, { expanded, outputPad: this.host.outputPad() }, this.theme()));
308
+ return component ? this.own(component, fallback) : fallback;
309
+ }
310
+ renderTool(name: string, input: unknown, result: { text: string; ok: boolean; data?: unknown } | null, expanded: boolean, callFallback: Component, resultFallback: Component, id: string, partial?: ExtensionToolResult): OwnedComponent {
311
+ const tool = this.registry?.tools.get(name);
312
+ const context: ExtensionToolRenderContext = { args: input, toolCallId: id, invalidate: () => this.host.screen().requestRender(), lastComponent: undefined,
313
+ state: this.toolStates.get(id) ?? {}, cwd: this.host.cwd(), executionStarted: true, argsComplete: true, isPartial: !!partial, expanded, showImages: this.host.showImages(), isError: result?.ok === false };
314
+ const slots: { raw?: ExtensionComponent; owned?: OwnedComponent; failed?: boolean }[] = [{}, {}];
315
+ const slot = (index: number, factory: (() => ExtensionComponent) | undefined, fallback: Component): Component => {
316
+ const current = slots[index]!;
317
+ if (!factory || current.failed) return fallback;
318
+ context.lastComponent = current.raw;
319
+ try {
320
+ const raw = factory();
321
+ if (raw !== current.raw) { current.owned?.dispose(); current.raw = raw; current.owned = this.own(raw, fallback); }
322
+ return current.owned ?? fallback;
323
+ } catch (error) { current.failed = true; this.report(error); return fallback; }
324
+ finally { this.toolStates.set(id, context.state); }
325
+ };
326
+ const components = () => [slot(0, tool?.renderCall ? () => tool.renderCall!(input, this.theme(), context) : undefined, callFallback),
327
+ slot(1, result && tool?.renderResult ? () => tool.renderResult!(partial ?? {content:[{type:"text",text:result.text}], ...(result.data === undefined ? {} : { details: result.data as import("../core/tool-contract.ts").JsonValue }),isError:!result.ok}, {expanded,isPartial:!!partial},this.theme(),context) : undefined, resultFallback)];
328
+ return { render: width => components().flatMap(c => c.render(width)), invalidate: () => slots.forEach(slot => slot.owned?.invalidate()), dispose: () => slots.forEach(slot => slot.owned?.dispose()) };
329
+ }
330
+ }
package/src/apps/input.ts CHANGED
@@ -14,6 +14,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
14
14
  import { fuzzyFilter } from "../tui/fuzzy.ts";
15
15
  import type { FileIndex } from "../store/file-index.ts";
16
16
  import { fileReferences } from "../core/file-references.ts";
17
+ import type { ImageRef } from "../core/images.ts";
17
18
 
18
19
  /**
19
20
  * Per-file cap for @ expansion. Assumed, not measured: 256KB of one file
@@ -38,6 +39,7 @@ export const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
38
39
  export const COMMAND_TIMEOUT_MS = 120_000;
39
40
 
40
41
  export interface ExpandContext {
42
+ readonly attachImage?: (path: string) => Promise<ImageRef | null>;
41
43
  readonly workspace: string;
42
44
  readonly index: FileIndex;
43
45
  /** Terminal writer for !!cmd (and user-visible notices). */
@@ -48,6 +50,7 @@ export interface ExpandContext {
48
50
  }
49
51
 
50
52
  export interface ExpandResult {
53
+ readonly images?: readonly ImageRef[];
51
54
  /** Text to send to the model. Empty when nothing should be sent. */
52
55
  readonly message: string;
53
56
  /** False when the input produced no model-facing text (a lone !!cmd). */
@@ -60,6 +63,9 @@ export function matchFiles(index: FileIndex, query: string): string[] {
60
63
 
61
64
  export async function expandInput(text: string, ctx: ExpandContext): Promise<ExpandResult> {
62
65
  const out: string[] = [];
66
+ const images: ImageRef[] = [];
67
+ const attachment = ctx.attachImage;
68
+ if (attachment) ctx = { ...ctx, attachImage: async path => { const image = await attachment(path); if (image) images.push(image); return image; } };
63
69
  const refs = fileReferences(text);
64
70
  let offset = 0;
65
71
  for (const line of text.split(/\r?\n/)) {
@@ -91,13 +97,18 @@ export async function expandInput(text: string, ctx: ExpandContext): Promise<Exp
91
97
  if (line.slice(end).trim()) out.push(line.slice(end).trimStart());
92
98
  }
93
99
  const message = out.join("\n");
94
- return { message, send: message.trim().length > 0 };
100
+ return { message, send: message.trim().length > 0 || images.length > 0, ...(images.length ? { images } : {}) };
95
101
  }
96
102
 
97
103
  async function expandReference(ref: string, ctx: ExpandContext): Promise<string> {
98
104
  const abs = confine(ctx.workspace, ref);
99
105
  if (abs === null) return skip(ctx, ref, "outside the workspace");
100
106
 
107
+ if (ctx.attachImage && /\.(png|jpe?g|gif|webp|bmp)$/i.test(abs)) {
108
+ const image = await ctx.attachImage(abs).catch(error => { if ((error as { code?: string }).code === "ENOENT") return null; throw error; });
109
+ if (image) return `[Image: ${image.name}, ${image.width}x${image.height}]`;
110
+ }
111
+
101
112
  const got = await tryRead(abs);
102
113
  if (got.status === "ok") return renderFile(posix(ref), got, ctx);
103
114
  if (got.reason !== "not found") return skip(ctx, posix(ref), got.reason);
@@ -108,6 +119,10 @@ async function expandReference(ref: string, ctx: ExpandContext): Promise<string>
108
119
  const path = hits[0]!;
109
120
  const hitAbs = confine(ctx.workspace, path);
110
121
  if (hitAbs === null) return skip(ctx, path, "outside the workspace");
122
+ if (ctx.attachImage && /\.(png|jpe?g|gif|webp|bmp)$/i.test(hitAbs)) {
123
+ const image = await ctx.attachImage(hitAbs);
124
+ if (image) return `[Image: ${image.name}, ${image.width}x${image.height}]`;
125
+ }
111
126
  const hit = await tryRead(hitAbs);
112
127
  if (hit.status === "ok") return renderFile(path, hit, ctx);
113
128
  return skip(ctx, path, hit.reason);
@@ -0,0 +1,42 @@
1
+ import { modelKey } from "../core/session-position.ts";
2
+ import { clampThinkingLevel } from "../core/thinking.ts";
3
+ import { createProvider } from "../runtime/catalog.ts";
4
+ import { storedIsOAuth } from "../runtime/auth.ts";
5
+ import { outputTokenBudget, type ModelSpec } from "../runtime/model.ts";
6
+ import { providerAccess, setProviderAccess } from "../runtime/provider-access.ts";
7
+ import { providerReplayKey } from "../runtime/model-replay.ts";
8
+ import { retryHandler, type App } from "./startup.ts";
9
+
10
+ /** Publish the active profile with the directory. A changed wire identity checkpoints signed history (D8). */
11
+ export async function publishModelCatalog(app: App, models: readonly ModelSpec[], checkpointed = false): Promise<boolean> {
12
+ if (app.session.running) throw new Error("cannot reload model configuration during a turn");
13
+ const previous = app.providerConfig.spec;
14
+ let spec = models.find(candidate => modelKey(candidate) === modelKey(previous));
15
+ if (!spec) {
16
+ spec = { ...previous };
17
+ const reason = `${modelKey(previous)} is disabled or absent from the catalog; choose /model before sending`;
18
+ setProviderAccess(spec, { invalid: reason });
19
+ app.providerConfig = { ...app.providerConfig, spec };
20
+ app.credential = { ok: false, reason };
21
+ app.catalog = models;
22
+ app.io.say(app.io.palette.yellow(` ${reason}`));
23
+ return false;
24
+ }
25
+ if (JSON.stringify([previous, providerAccess(previous)]) === JSON.stringify([spec, providerAccess(spec)])) {
26
+ app.catalog = models;
27
+ return false;
28
+ }
29
+ const config = { ...app.providerConfig, spec, maxTokens: outputTokenBudget(spec), effort: clampThinkingLevel(spec, app.providerConfig.effort) };
30
+ const credential = app.credentialOf(spec);
31
+ const next = createProvider(config, credential.ok ? credential.key : "", retryHandler(app.io), {
32
+ effects: app.effects, oauth: !app.args.apiKey && storedIsOAuth(app.auth, spec.provider),
33
+ });
34
+ const needsCut = providerReplayKey(previous) !== providerReplayKey(spec);
35
+ if (needsCut && !checkpointed) await app.session.switchModel(next);
36
+ else app.session.provider = next;
37
+ app.providerConfig = config;
38
+ app.credential = credential;
39
+ app.catalog = models;
40
+ if (app.ui) app.ui.settings = { ...app.ui.settings, thinking: config.effort };
41
+ return needsCut && !checkpointed;
42
+ }