@visual-z/xeno 0.1.4 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +143 -33
- package/docs/install.md +18 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +42 -14
- package/src/apps/commands.ts +32 -4
- package/src/apps/completions.ts +9 -6
- package/src/apps/export-cli.ts +24 -0
- package/src/apps/extension-ui.ts +330 -0
- package/src/apps/input.ts +16 -1
- package/src/apps/model-catalog.ts +42 -0
- package/src/apps/project-trust.ts +49 -0
- package/src/apps/render.ts +1 -0
- package/src/apps/resource-browser.ts +75 -0
- package/src/apps/resources.ts +25 -0
- package/src/apps/session-browser.ts +113 -0
- package/src/apps/session-picker.ts +55 -0
- package/src/apps/shell.ts +1177 -378
- package/src/apps/startup.ts +400 -268
- package/src/core/action-defaults.ts +306 -0
- package/src/core/attachment-input.ts +17 -0
- package/src/core/catalog-merge.ts +19 -0
- package/src/core/event-validation.ts +19 -2
- package/src/core/events.ts +45 -2
- package/src/core/export.ts +6 -2
- package/src/core/extension-flags.ts +14 -0
- package/src/core/images.ts +46 -0
- package/src/core/json-event.ts +2 -0
- package/src/core/keymap.ts +43 -119
- package/src/core/model-replay.ts +22 -0
- package/src/core/model-scope.ts +53 -0
- package/src/core/packages.ts +47 -0
- package/src/core/project.ts +21 -3
- package/src/core/prompts.ts +33 -5
- package/src/core/queue.ts +18 -0
- package/src/core/resource-selection.ts +36 -0
- package/src/{runtime → core}/resume.ts +15 -22
- package/src/core/session-archive.ts +4 -0
- package/src/core/session-browser.ts +82 -0
- package/src/core/session-entry.ts +56 -0
- package/src/core/session-position.ts +26 -0
- package/src/core/settings.ts +169 -154
- package/src/core/theme.ts +15 -6
- package/src/core/thinking.ts +26 -0
- package/src/core/tokens.ts +2 -0
- package/src/core/transcript.ts +15 -5
- package/src/runtime/auth.ts +116 -11
- package/src/runtime/catalog.ts +54 -151
- package/src/runtime/clipboard.ts +114 -0
- package/src/runtime/extension-loader.ts +37 -0
- package/src/runtime/extension-ui.ts +55 -0
- package/src/runtime/extensions.ts +273 -0
- package/src/runtime/images.ts +76 -0
- package/src/runtime/loop.ts +213 -83
- package/src/runtime/model-config.ts +232 -0
- package/src/runtime/model-data.LICENSE +27 -0
- package/src/runtime/model-data.ts +21 -0
- package/src/runtime/model-replay.ts +10 -0
- package/src/runtime/model-scope.ts +20 -0
- package/src/runtime/model-sources.ts +151 -0
- package/src/runtime/model.ts +74 -11
- package/src/runtime/models-fetch.ts +6 -104
- package/src/runtime/oauth.ts +4 -3
- package/src/runtime/openai-responses.ts +12 -4
- package/src/runtime/openai.ts +13 -4
- package/src/runtime/package-manager.ts +125 -0
- package/src/runtime/pi-provider.ts +235 -0
- package/src/runtime/progress.ts +26 -0
- package/src/runtime/provider-access.ts +21 -0
- package/src/runtime/provider-protocols.ts +29 -0
- package/src/runtime/provider-request.ts +73 -0
- package/src/runtime/provider.ts +26 -6
- package/src/runtime/rich-text.ts +36 -0
- package/src/runtime/tools.ts +41 -5
- package/src/store/command.ts +7 -1
- package/src/store/context-files.ts +1 -1
- package/src/store/log.ts +65 -0
- package/src/store/models.ts +28 -9
- package/src/store/observations.ts +4 -2
- package/src/store/packages.ts +98 -0
- package/src/store/recovery.ts +8 -0
- package/src/store/resource-index.ts +170 -0
- package/src/store/resources.ts +75 -142
- package/src/store/session-archive.ts +162 -0
- package/src/store/session-browser.ts +68 -0
- package/src/store/session-position.ts +27 -0
- package/src/store/sessions.ts +87 -66
- package/src/store/trust.ts +13 -0
- package/src/tui/chat-viewport.ts +47 -0
- package/src/tui/editor.ts +62 -15
- package/src/tui/host.ts +50 -9
- package/src/tui/input.ts +8 -2
- package/src/tui/select.ts +24 -6
- package/src/tui/settings.ts +4 -0
- package/src/tui/system-theme.ts +700 -0
- package/src/tui/transcript.ts +63 -11
- package/src/tui/tree.ts +161 -95
- package/src/vendor/pi-rich/LICENSE.upstream +21 -0
- package/src/vendor/pi-rich/UPSTREAM.json +17 -0
- package/src/vendor/pi-rich/clipboard-command.ts +44 -0
- package/src/vendor/pi-rich/exif-orientation.ts +182 -0
- package/src/vendor/pi-rich/html.ts +51 -0
- package/src/vendor/pi-rich/image-convert.ts +49 -0
- package/src/vendor/pi-rich/image-resize-core.ts +164 -0
- package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
- package/src/vendor/pi-rich/image-resize.ts +123 -0
- package/src/vendor/pi-rich/mime.ts +116 -0
- package/src/vendor/pi-rich/photon.ts +139 -0
- package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
- package/src/vendor/pi-tui/UPSTREAM.json +50 -0
- package/src/vendor/pi-tui/VENDOR.md +7 -5
- package/src/vendor/pi-tui/autocomplete.ts +35 -10
- package/src/vendor/pi-tui/colors.ts +367 -0
- package/src/vendor/pi-tui/components/box.ts +8 -9
- package/src/vendor/pi-tui/components/editor.ts +4 -2
- package/src/vendor/pi-tui/components/markdown.ts +8 -2
- package/src/vendor/pi-tui/index.ts +31 -1
- package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
- package/src/vendor/pi-tui/native/clipboard.h +208 -0
- package/src/vendor/pi-tui/native/darwin/README.md +22 -0
- package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
- package/src/vendor/pi-tui/native/linux/README.md +23 -0
- package/src/vendor/pi-tui/native/linux/build.sh +65 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
- package/src/vendor/pi-tui/native/napi.h +91 -0
- package/src/vendor/pi-tui/native/win32/README.md +31 -0
- package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
- package/src/vendor/pi-tui/native-platform.ts +2 -0
- package/src/vendor/pi-tui/oklab.ts +233 -0
- package/src/vendor/pi-tui/terminal-colors.ts +26 -8
- package/src/vendor/pi-tui/terminal-image.ts +43 -8
- package/src/vendor/pi-tui/terminal.ts +16 -9
- package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
- package/src/vendor/pi-tui/tui.ts +112 -75
- package/src/vendor/pi-tui/utils.ts +89 -49
- package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
package/src/apps/commands.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
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
|
-
|
|
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) {
|
package/src/apps/completions.ts
CHANGED
|
@@ -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(
|
|
10
|
-
thinking: () => [
|
|
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: () => [
|
|
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
|
+
}
|