@visual-z/xeno 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -0,0 +1,182 @@
1
+ import type { PhotonImageType } from "./photon.ts";
2
+
3
+ type Photon = typeof import("@silvia-odwyer/photon-node");
4
+
5
+ function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number {
6
+ if (tiffStart + 8 > bytes.length) return 1;
7
+
8
+ const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
9
+ const le = byteOrder === 0x4949;
10
+
11
+ const read16 = (pos: number): number => {
12
+ if (le) return bytes[pos] | (bytes[pos + 1] << 8);
13
+ return (bytes[pos] << 8) | bytes[pos + 1];
14
+ };
15
+
16
+ const read32 = (pos: number): number => {
17
+ if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
18
+ return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
19
+ };
20
+
21
+ const ifdOffset = read32(tiffStart + 4);
22
+ const ifdStart = tiffStart + ifdOffset;
23
+ if (ifdStart + 2 > bytes.length) return 1;
24
+
25
+ const entryCount = read16(ifdStart);
26
+ for (let i = 0; i < entryCount; i++) {
27
+ const entryPos = ifdStart + 2 + i * 12;
28
+ if (entryPos + 12 > bytes.length) return 1;
29
+
30
+ if (read16(entryPos) === 0x0112) {
31
+ const value = read16(entryPos + 8);
32
+ return value >= 1 && value <= 8 ? value : 1;
33
+ }
34
+ }
35
+
36
+ return 1;
37
+ }
38
+
39
+ function findJpegTiffOffset(bytes: Uint8Array): number {
40
+ let offset = 2;
41
+ while (offset < bytes.length - 1) {
42
+ if (bytes[offset] !== 0xff) return -1;
43
+ const marker = bytes[offset + 1];
44
+ if (marker === 0xff) {
45
+ offset++;
46
+ continue;
47
+ }
48
+
49
+ if (marker === 0xe1) {
50
+ if (offset + 4 >= bytes.length) return -1;
51
+ const segmentStart = offset + 4;
52
+ if (segmentStart + 6 > bytes.length) return -1;
53
+ if (hasExifHeader(bytes, segmentStart)) return segmentStart + 6;
54
+ }
55
+
56
+ if (offset + 4 > bytes.length) return -1;
57
+ const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
58
+ offset += 2 + length;
59
+ }
60
+
61
+ return -1;
62
+ }
63
+
64
+ function findWebpTiffOffset(bytes: Uint8Array): number {
65
+ let offset = 12;
66
+ while (offset + 8 <= bytes.length) {
67
+ const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
68
+ const chunkSize =
69
+ bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
70
+ const dataStart = offset + 8;
71
+
72
+ if (chunkId === "EXIF") {
73
+ if (dataStart + chunkSize > bytes.length) return -1;
74
+ // Some WebP files have "Exif\0\0" prefix before the TIFF header
75
+ const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
76
+ return tiffStart;
77
+ }
78
+
79
+ // RIFF chunks are padded to even size
80
+ offset = dataStart + chunkSize + (chunkSize % 2);
81
+ }
82
+
83
+ return -1;
84
+ }
85
+
86
+ function hasExifHeader(bytes: Uint8Array, offset: number): boolean {
87
+ return (
88
+ bytes[offset] === 0x45 &&
89
+ bytes[offset + 1] === 0x78 &&
90
+ bytes[offset + 2] === 0x69 &&
91
+ bytes[offset + 3] === 0x66 &&
92
+ bytes[offset + 4] === 0x00 &&
93
+ bytes[offset + 5] === 0x00
94
+ );
95
+ }
96
+
97
+ function getExifOrientation(bytes: Uint8Array): number {
98
+ let tiffOffset = -1;
99
+
100
+ // JPEG: starts with FF D8
101
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
102
+ tiffOffset = findJpegTiffOffset(bytes);
103
+ }
104
+ // WebP: starts with RIFF....WEBP
105
+ else if (
106
+ bytes.length >= 12 &&
107
+ bytes[0] === 0x52 &&
108
+ bytes[1] === 0x49 &&
109
+ bytes[2] === 0x46 &&
110
+ bytes[3] === 0x46 &&
111
+ bytes[8] === 0x57 &&
112
+ bytes[9] === 0x45 &&
113
+ bytes[10] === 0x42 &&
114
+ bytes[11] === 0x50
115
+ ) {
116
+ tiffOffset = findWebpTiffOffset(bytes);
117
+ }
118
+
119
+ if (tiffOffset === -1) return 1;
120
+ return readOrientationFromTiff(bytes, tiffOffset);
121
+ }
122
+
123
+ type DstIndexFn = (x: number, y: number, w: number, h: number) => number;
124
+
125
+ function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType {
126
+ const w = image.get_width();
127
+ const h = image.get_height();
128
+ const src = image.get_raw_pixels();
129
+ const dst = new Uint8Array(src.length);
130
+
131
+ for (let y = 0; y < h; y++) {
132
+ for (let x = 0; x < w; x++) {
133
+ const srcIdx = (y * w + x) * 4;
134
+ const dstIdx = dstIndex(x, y, w, h) * 4;
135
+ dst[dstIdx] = src[srcIdx];
136
+ dst[dstIdx + 1] = src[srcIdx + 1];
137
+ dst[dstIdx + 2] = src[srcIdx + 2];
138
+ dst[dstIdx + 3] = src[srcIdx + 3];
139
+ }
140
+ }
141
+
142
+ return new photon.PhotonImage(dst, h, w);
143
+ }
144
+
145
+ // Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
146
+ export function applyExifOrientation(
147
+ photon: Photon,
148
+ image: PhotonImageType,
149
+ originalBytes: Uint8Array,
150
+ ): PhotonImageType {
151
+ const orientation = getExifOrientation(originalBytes);
152
+ if (orientation === 1) return image;
153
+
154
+ switch (orientation) {
155
+ case 2:
156
+ photon.fliph(image);
157
+ return image;
158
+ case 3:
159
+ photon.fliph(image);
160
+ photon.flipv(image);
161
+ return image;
162
+ case 4:
163
+ photon.flipv(image);
164
+ return image;
165
+ case 5: {
166
+ const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
167
+ photon.fliph(rotated);
168
+ return rotated;
169
+ }
170
+ case 6:
171
+ return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
172
+ case 7: {
173
+ const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
174
+ photon.fliph(rotated);
175
+ return rotated;
176
+ }
177
+ case 8:
178
+ return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
179
+ default:
180
+ return image;
181
+ }
182
+ }
@@ -0,0 +1,51 @@
1
+ export interface DecodedHtmlEntity {
2
+ text: string;
3
+ length: number;
4
+ }
5
+
6
+ function decodeCodePoint(codePoint: number): string | undefined {
7
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
8
+ return undefined;
9
+ }
10
+ return String.fromCodePoint(codePoint);
11
+ }
12
+
13
+ export function decodeHtmlEntity(entity: string): string | undefined {
14
+ switch (entity) {
15
+ case "amp":
16
+ return "&";
17
+ case "lt":
18
+ return "<";
19
+ case "gt":
20
+ return ">";
21
+ case "quot":
22
+ return '"';
23
+ case "apos":
24
+ return "'";
25
+ }
26
+
27
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
28
+ return decodeCodePoint(Number.parseInt(entity.slice(2), 16));
29
+ }
30
+
31
+ if (entity.startsWith("#")) {
32
+ return decodeCodePoint(Number.parseInt(entity.slice(1), 10));
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
+ export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined {
39
+ const semicolonIndex = html.indexOf(";", index + 1);
40
+ if (semicolonIndex === -1 || semicolonIndex - index > 16) {
41
+ return undefined;
42
+ }
43
+
44
+ const entity = html.slice(index + 1, semicolonIndex);
45
+ const decoded = decodeHtmlEntity(entity);
46
+ if (decoded === undefined) {
47
+ return undefined;
48
+ }
49
+
50
+ return { text: decoded, length: semicolonIndex - index + 1 };
51
+ }
@@ -0,0 +1,49 @@
1
+ import { applyExifOrientation } from "./exif-orientation.ts";
2
+ import { loadPhoton } from "./photon.ts";
3
+
4
+ export async function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
5
+ const photon = await loadPhoton();
6
+ if (!photon) {
7
+ // Photon not available, can't convert
8
+ return null;
9
+ }
10
+
11
+ try {
12
+ const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
13
+ const image = applyExifOrientation(photon, rawImage, bytes);
14
+ if (image !== rawImage) rawImage.free();
15
+ try {
16
+ return new Uint8Array(image.get_bytes());
17
+ } finally {
18
+ image.free();
19
+ }
20
+ } catch {
21
+ // Conversion failed
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Convert image to PNG format for terminal display.
28
+ * Kitty graphics protocol requires PNG format (f=100).
29
+ */
30
+ export async function convertToPng(
31
+ base64Data: string,
32
+ mimeType: string,
33
+ ): Promise<{ data: string; mimeType: string } | null> {
34
+ // Already PNG, no conversion needed
35
+ if (mimeType === "image/png") {
36
+ return { data: base64Data, mimeType };
37
+ }
38
+
39
+ const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
40
+ const pngBytes = await convertImageBytesToPng(bytes);
41
+ if (!pngBytes) {
42
+ return null;
43
+ }
44
+
45
+ return {
46
+ data: Buffer.from(pngBytes).toString("base64"),
47
+ mimeType: "image/png",
48
+ };
49
+ }
@@ -0,0 +1,164 @@
1
+ import { applyExifOrientation } from "./exif-orientation.ts";
2
+ import { loadPhoton } from "./photon.ts";
3
+
4
+ export interface ImageResizeOptions {
5
+ maxWidth?: number; // Default: 2000
6
+ maxHeight?: number; // Default: 2000
7
+ maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
8
+ jpegQuality?: number; // Default: 80
9
+ }
10
+
11
+ export interface ResizedImage {
12
+ data: string; // base64
13
+ mimeType: string;
14
+ originalWidth: number;
15
+ originalHeight: number;
16
+ width: number;
17
+ height: number;
18
+ wasResized: boolean;
19
+ }
20
+
21
+ // 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
22
+ const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
23
+
24
+ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
25
+ maxWidth: 2000,
26
+ maxHeight: 2000,
27
+ maxBytes: DEFAULT_MAX_BYTES,
28
+ jpegQuality: 80,
29
+ };
30
+
31
+ interface EncodedCandidate {
32
+ data: string;
33
+ encodedSize: number;
34
+ mimeType: string;
35
+ }
36
+
37
+ function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
38
+ const data = Buffer.from(buffer).toString("base64");
39
+ return {
40
+ data,
41
+ encodedSize: Buffer.byteLength(data, "utf-8"),
42
+ mimeType,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Resize an image to fit within the specified max dimensions and encoded file size.
48
+ * Returns null if the image cannot be resized below maxBytes.
49
+ *
50
+ * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
51
+ * returns null.
52
+ *
53
+ * Strategy for staying under maxBytes:
54
+ * 1. First resize to maxWidth/maxHeight
55
+ * 2. Try both PNG and JPEG formats, pick the smaller one
56
+ * 3. If still too large, try JPEG with decreasing quality
57
+ * 4. If still too large, progressively reduce dimensions until 1x1
58
+ */
59
+ export async function resizeImageInProcess(
60
+ inputBytes: Uint8Array,
61
+ mimeType: string,
62
+ options?: ImageResizeOptions,
63
+ ): Promise<ResizedImage | null> {
64
+ const opts = { ...DEFAULT_OPTIONS, ...options };
65
+ const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
66
+
67
+ const photon = await loadPhoton();
68
+ if (!photon) {
69
+ return null;
70
+ }
71
+
72
+ let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
73
+ try {
74
+ const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
75
+ image = applyExifOrientation(photon, rawImage, inputBytes);
76
+ if (image !== rawImage) rawImage.free();
77
+
78
+ const originalWidth = image.get_width();
79
+ const originalHeight = image.get_height();
80
+ const format = mimeType.split("/")[1] ?? "png";
81
+
82
+ // Check if already within all limits (dimensions AND encoded size)
83
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
84
+ return {
85
+ data: Buffer.from(inputBytes).toString("base64"),
86
+ mimeType: mimeType || `image/${format}`,
87
+ originalWidth,
88
+ originalHeight,
89
+ width: originalWidth,
90
+ height: originalHeight,
91
+ wasResized: false,
92
+ };
93
+ }
94
+
95
+ // Calculate initial dimensions respecting max limits
96
+ let targetWidth = originalWidth;
97
+ let targetHeight = originalHeight;
98
+
99
+ if (targetWidth > opts.maxWidth) {
100
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
101
+ targetWidth = opts.maxWidth;
102
+ }
103
+ if (targetHeight > opts.maxHeight) {
104
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
105
+ targetHeight = opts.maxHeight;
106
+ }
107
+
108
+ function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
109
+ const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
110
+
111
+ try {
112
+ const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
113
+ for (const quality of jpegQualities) {
114
+ candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
115
+ }
116
+ return candidates;
117
+ } finally {
118
+ resized.free();
119
+ }
120
+ }
121
+
122
+ const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
123
+ let currentWidth = targetWidth;
124
+ let currentHeight = targetHeight;
125
+
126
+ while (true) {
127
+ const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
128
+ for (const candidate of candidates) {
129
+ if (candidate.encodedSize < opts.maxBytes) {
130
+ return {
131
+ data: candidate.data,
132
+ mimeType: candidate.mimeType,
133
+ originalWidth,
134
+ originalHeight,
135
+ width: currentWidth,
136
+ height: currentHeight,
137
+ wasResized: true,
138
+ };
139
+ }
140
+ }
141
+
142
+ if (currentWidth === 1 && currentHeight === 1) {
143
+ break;
144
+ }
145
+
146
+ const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
147
+ const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
148
+ if (nextWidth === currentWidth && nextHeight === currentHeight) {
149
+ break;
150
+ }
151
+
152
+ currentWidth = nextWidth;
153
+ currentHeight = nextHeight;
154
+ }
155
+
156
+ return null;
157
+ } catch {
158
+ return null;
159
+ } finally {
160
+ if (image) {
161
+ image.free();
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,42 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
3
+
4
+ interface ResizeImageWorkerRequest {
5
+ inputBytes: Uint8Array;
6
+ mimeType: string;
7
+ options?: ImageResizeOptions;
8
+ }
9
+
10
+ interface ResizeImageWorkerResponse {
11
+ result?: ResizedImage | null;
12
+ error?: string;
13
+ }
14
+
15
+ function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest {
16
+ if (!value || typeof value !== "object") return false;
17
+ const record = value as Record<string, unknown>;
18
+ return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
19
+ }
20
+
21
+ const port = parentPort;
22
+ if (!port) {
23
+ throw new Error("image resize worker requires parentPort");
24
+ }
25
+
26
+ port.once("message", (message: unknown) => {
27
+ void (async () => {
28
+ try {
29
+ if (!isResizeImageWorkerRequest(message)) {
30
+ throw new Error("Invalid image resize worker request");
31
+ }
32
+ const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
33
+ const response: ResizeImageWorkerResponse = { result };
34
+ port.postMessage(response);
35
+ } catch (error) {
36
+ const response: ResizeImageWorkerResponse = {
37
+ error: error instanceof Error ? error.message : String(error),
38
+ };
39
+ port.postMessage(response);
40
+ }
41
+ })();
42
+ });
@@ -0,0 +1,123 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts";
3
+
4
+ export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts";
5
+
6
+ interface ResizeImageWorkerResponse {
7
+ result?: ResizedImage | null;
8
+ error?: string;
9
+ }
10
+
11
+ function toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {
12
+ // Transfer detaches the buffer, so transfer a worker-owned copy and leave the
13
+ // caller's bytes intact.
14
+ return new Uint8Array(input);
15
+ }
16
+
17
+ function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
18
+ return value !== null && typeof value === "object";
19
+ }
20
+
21
+ function createResizeWorker(workerSpecifier: string | URL): Worker {
22
+ return new Worker(workerSpecifier);
23
+ }
24
+
25
+ async function resizeImageInWorker(
26
+ workerSpecifier: string | URL,
27
+ inputBytes: Uint8Array,
28
+ mimeType: string,
29
+ options?: ImageResizeOptions,
30
+ ): Promise<ResizedImage | null> {
31
+ const worker = createResizeWorker(workerSpecifier);
32
+ try {
33
+ const inputBytesForWorker = toTransferableBytes(inputBytes);
34
+ return await new Promise<ResizedImage | null>((resolve, reject) => {
35
+ let settled = false;
36
+ const settle = (result: ResizedImage | null): void => {
37
+ if (settled) return;
38
+ settled = true;
39
+ resolve(result);
40
+ };
41
+ const fail = (error: Error): void => {
42
+ if (settled) return;
43
+ settled = true;
44
+ reject(error);
45
+ };
46
+
47
+ worker.once("message", (message: unknown) => {
48
+ if (!isResizeImageWorkerResponse(message)) {
49
+ fail(new Error("Invalid image resize worker response"));
50
+ return;
51
+ }
52
+ if (message.error) {
53
+ fail(new Error(message.error));
54
+ return;
55
+ }
56
+ settle(message.result ?? null);
57
+ });
58
+ worker.once("error", fail);
59
+ worker.once("exit", (code) => {
60
+ if (!settled) {
61
+ fail(new Error(`Image resize worker exited with code ${code}`));
62
+ }
63
+ });
64
+ worker.postMessage(
65
+ {
66
+ inputBytes: inputBytesForWorker,
67
+ mimeType,
68
+ options,
69
+ },
70
+ [inputBytesForWorker.buffer],
71
+ );
72
+ });
73
+ } finally {
74
+ void worker.terminate().catch(() => undefined);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Resize an image to fit within the specified max dimensions and encoded file size.
80
+ * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not
81
+ * block the TUI event loop. If the worker cannot be loaded (for example in some
82
+ * Bun compiled executable layouts), fall back to in-process resizing so image
83
+ * reads still work.
84
+ */
85
+ export async function resizeImage(
86
+ inputBytes: Uint8Array,
87
+ mimeType: string,
88
+ options?: ImageResizeOptions,
89
+ ): Promise<ResizedImage | null> {
90
+ const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
91
+ const workerUrl = new URL(
92
+ isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js",
93
+ import.meta.url,
94
+ );
95
+
96
+ // Bun compiled executables resolve worker entrypoints by string path, not via
97
+ // new URL(..., import.meta.url). Try the string path first under Bun so the
98
+ // release binary uses the embedded worker instead of falling back in-process.
99
+ if (typeof process.versions.bun === "string") {
100
+ try {
101
+ return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options);
102
+ } catch {}
103
+ }
104
+
105
+ try {
106
+ return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options);
107
+ } catch {
108
+ return resizeImageInProcess(inputBytes, mimeType, options);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Format a dimension note for resized images.
114
+ * This helps the model understand the coordinate mapping.
115
+ */
116
+ export function formatDimensionNote(result: ResizedImage): string | undefined {
117
+ if (!result.wasResized) {
118
+ return undefined;
119
+ }
120
+
121
+ const scale = result.originalWidth / result.width;
122
+ return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
123
+ }