@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,241 @@
1
+ #define PI_CLIPBOARD_WRITE
2
+ #include "../../clipboard.h"
3
+
4
+ #ifndef BI_ALPHABITFIELDS
5
+ #define BI_ALPHABITFIELDS 6
6
+ #endif
7
+
8
+ #define KEY_PRESSED_MASK 0x8000
9
+ #define OPEN_CLIPBOARD_ATTEMPTS 10
10
+ #define OPEN_CLIPBOARD_RETRY_MS 5
11
+ #define BITMAP_FILE_HEADER_SIZE 14
12
+
13
+ static int string_equals(const char* left, const char* right) {
14
+ while (*left && *right && *left == *right) {
15
+ left++;
16
+ right++;
17
+ }
18
+ return *left == 0 && *right == 0;
19
+ }
20
+
21
+ static int is_key_pressed(int virtual_key) {
22
+ return ((unsigned short)GetAsyncKeyState(virtual_key) & KEY_PRESSED_MASK) != 0;
23
+ }
24
+
25
+ static int is_modifier_name_pressed(const char* name) {
26
+ if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT);
27
+ if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL);
28
+ if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU);
29
+ if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN);
30
+ return 0;
31
+ }
32
+
33
+ static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
34
+ (void)info;
35
+
36
+ HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
37
+ DWORD mode = 0;
38
+ bool enabled = handle != INVALID_HANDLE_VALUE &&
39
+ GetConsoleMode(handle, &mode) &&
40
+ SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);
41
+
42
+ napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
43
+ napi_value result = 0;
44
+ if (!napi_get_boolean || napi_get_boolean(env, enabled, &result) != 0) {
45
+ return fail(env, "Could not configure console input");
46
+ }
47
+ return result;
48
+ }
49
+
50
+ static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) {
51
+ napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
52
+ napi_get_value_string_utf8_fn napi_get_value_string_utf8 =
53
+ (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
54
+ napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
55
+
56
+ bool pressed = false;
57
+ if (napi_get_cb_info && napi_get_value_string_utf8) {
58
+ size_t argc = 1;
59
+ napi_value args[1] = {0};
60
+ if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
61
+ char name[16] = {0};
62
+ size_t copied = 0;
63
+ if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
64
+ pressed = is_modifier_name_pressed(name);
65
+ }
66
+ }
67
+ }
68
+
69
+ napi_value result = 0;
70
+ if (!napi_get_boolean || napi_get_boolean(env, pressed, &result) != 0) {
71
+ return fail(env, "Could not inspect modifier state");
72
+ }
73
+ return result;
74
+ }
75
+
76
+ static bool open_clipboard(HWND owner) {
77
+ for (int attempt = 0; attempt < OPEN_CLIPBOARD_ATTEMPTS; attempt++) {
78
+ if (OpenClipboard(owner)) return true;
79
+ Sleep(OPEN_CLIPBOARD_RETRY_MS);
80
+ }
81
+ return false;
82
+ }
83
+
84
+ static uint16_t read_u16(const uint8_t* data) {
85
+ return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
86
+ }
87
+
88
+ static uint32_t read_u32(const uint8_t* data) {
89
+ return (uint32_t)data[0] |
90
+ ((uint32_t)data[1] << 8) |
91
+ ((uint32_t)data[2] << 16) |
92
+ ((uint32_t)data[3] << 24);
93
+ }
94
+
95
+ static void write_u32(uint8_t* data, uint32_t value) {
96
+ data[0] = (uint8_t)value;
97
+ data[1] = (uint8_t)(value >> 8);
98
+ data[2] = (uint8_t)(value >> 16);
99
+ data[3] = (uint8_t)(value >> 24);
100
+ }
101
+
102
+ static void copy_bytes(uint8_t* destination, const uint8_t* source, size_t length) {
103
+ volatile uint8_t* target = destination;
104
+ const volatile uint8_t* input = source;
105
+ for (size_t index = 0; index < length; index++) target[index] = input[index];
106
+ }
107
+
108
+ static size_t dib_pixel_offset(const uint8_t* dib, size_t size) {
109
+ if (size < 12) return 0;
110
+ uint32_t header_size = read_u32(dib);
111
+
112
+ if (header_size == 12) {
113
+ uint16_t bits_per_pixel = read_u16(dib + 10);
114
+ uint32_t color_count = bits_per_pixel <= 8 ? 1u << bits_per_pixel : 0;
115
+ size_t offset = header_size + (size_t)color_count * 3;
116
+ return offset <= size ? offset : 0;
117
+ }
118
+
119
+ if (header_size < 40 || header_size > size) return 0;
120
+ uint16_t bits_per_pixel = read_u16(dib + 14);
121
+ uint32_t compression = read_u32(dib + 16);
122
+ uint32_t color_count = read_u32(dib + 32);
123
+ if (!color_count && bits_per_pixel <= 8) color_count = 1u << bits_per_pixel;
124
+
125
+ size_t offset = header_size + (size_t)color_count * 4;
126
+ if (header_size == 40 && compression == BI_BITFIELDS) offset += 3 * sizeof(uint32_t);
127
+ if (header_size == 40 && compression == BI_ALPHABITFIELDS) offset += 4 * sizeof(uint32_t);
128
+ return offset <= size ? offset : 0;
129
+ }
130
+
131
+ static void read_clipboard_image(clipboard_job* job) {
132
+ UINT png_format = RegisterClipboardFormatW(L"PNG");
133
+ if (!(png_format && IsClipboardFormatAvailable(png_format)) &&
134
+ !IsClipboardFormatAvailable(CF_DIBV5) && !IsClipboardFormatAvailable(CF_DIB)) {
135
+ job->format = CLIPBOARD_EMPTY;
136
+ return;
137
+ }
138
+ HGLOBAL handle = png_format && IsClipboardFormatAvailable(png_format)
139
+ ? (HGLOBAL)GetClipboardData(png_format) : 0;
140
+ bool is_png = handle != 0;
141
+ if (!handle && IsClipboardFormatAvailable(CF_DIBV5)) handle = (HGLOBAL)GetClipboardData(CF_DIBV5);
142
+ if (!handle && IsClipboardFormatAvailable(CF_DIB)) handle = (HGLOBAL)GetClipboardData(CF_DIB);
143
+
144
+ const uint8_t* source = handle ? (const uint8_t*)GlobalLock(handle) : 0;
145
+ size_t size = handle ? GlobalSize(handle) : 0;
146
+ if (!source || !size) {
147
+ if (source) GlobalUnlock(handle);
148
+ job->error = "Clipboard does not contain an image";
149
+ return;
150
+ }
151
+ if (is_png) {
152
+ clipboard_copy(job, source, size, CLIPBOARD_BUFFER);
153
+ } else {
154
+ size_t offset = dib_pixel_offset(source, size);
155
+ uint8_t* bitmap = offset && size <= UINT32_MAX - BITMAP_FILE_HEADER_SIZE
156
+ ? clipboard_alloc(size + BITMAP_FILE_HEADER_SIZE) : 0;
157
+ if (bitmap) {
158
+ bitmap[0] = 'B';
159
+ bitmap[1] = 'M';
160
+ write_u32(bitmap + 2, (uint32_t)(size + BITMAP_FILE_HEADER_SIZE));
161
+ write_u32(bitmap + 10, (uint32_t)(BITMAP_FILE_HEADER_SIZE + offset));
162
+ copy_bytes(bitmap + BITMAP_FILE_HEADER_SIZE, source, size);
163
+ job->data = bitmap;
164
+ job->length = size + BITMAP_FILE_HEADER_SIZE;
165
+ job->format = CLIPBOARD_BUFFER;
166
+ } else {
167
+ job->error = "Could not create clipboard image buffer";
168
+ }
169
+ }
170
+ GlobalUnlock(handle);
171
+ }
172
+
173
+ static void clipboard_execute(clipboard_job* job) {
174
+ // A real clipboard owner is required for text writes, including under Node
175
+ // without a console. Create and destroy it on the same worker thread.
176
+ HWND owner = 0;
177
+ if (job->operation == CLIPBOARD_WRITE) {
178
+ owner = CreateWindowExW(0, L"STATIC", 0, 0, 0, 0, 0, 0,
179
+ HWND_MESSAGE, 0, GetModuleHandleA(0), 0);
180
+ if (!owner) {
181
+ job->error = "Could not create clipboard owner window";
182
+ return;
183
+ }
184
+ }
185
+ if (!open_clipboard(owner)) {
186
+ if (owner) DestroyWindow(owner);
187
+ job->error = "Could not open clipboard";
188
+ return;
189
+ }
190
+ if (job->operation == CLIPBOARD_WRITE) {
191
+ size_t size = (job->length + 1) * sizeof(uint16_t);
192
+ HGLOBAL handle = GlobalAlloc(GMEM_MOVEABLE, size);
193
+ void* text = handle ? GlobalLock(handle) : 0;
194
+ bool success = false;
195
+ if (text) {
196
+ copy_bytes(text, job->data, size);
197
+ GlobalUnlock(handle);
198
+ success = EmptyClipboard() && SetClipboardData(CF_UNICODETEXT, handle) != 0;
199
+ }
200
+ if (!success) {
201
+ if (handle) GlobalFree(handle);
202
+ job->error = "Could not set clipboard text";
203
+ }
204
+ } else if (job->operation == CLIPBOARD_TEXT) {
205
+ if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) {
206
+ job->format = CLIPBOARD_EMPTY;
207
+ } else {
208
+ HGLOBAL handle = (HGLOBAL)GetClipboardData(CF_UNICODETEXT);
209
+ const uint16_t* text = handle ? GlobalLock(handle) : 0;
210
+ if (text) {
211
+ size_t capacity = GlobalSize(handle) / sizeof(uint16_t);
212
+ size_t length = 0;
213
+ while (length < capacity && text[length]) length++;
214
+ clipboard_copy(job, text, length * sizeof(uint16_t), CLIPBOARD_UTF16);
215
+ GlobalUnlock(handle);
216
+ } else {
217
+ job->error = "Clipboard does not contain text";
218
+ }
219
+ }
220
+ } else {
221
+ read_clipboard_image(job);
222
+ }
223
+ CloseClipboard();
224
+ if (owner) DestroyWindow(owner);
225
+ }
226
+
227
+ BOOL WINAPI _DllMainCRTStartup(HINSTANCE instance, DWORD reason, LPVOID reserved) {
228
+ (void)instance;
229
+ (void)reason;
230
+ (void)reserved;
231
+ return TRUE;
232
+ }
233
+
234
+ PI_NAPI_EXPORT napi_value PI_NAPI_CALL napi_register_module_v1(napi_env env, napi_value exports) {
235
+ set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input);
236
+ set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
237
+ set_function_export(env, exports, "getText", get_clipboard_text);
238
+ set_function_export(env, exports, "setText", set_clipboard_text);
239
+ set_function_export(env, exports, "getImage", get_clipboard_image);
240
+ return exports;
241
+ }
@@ -11,6 +11,8 @@ export interface NativeClipboard {
11
11
  getText(): Promise<string | null | undefined>;
12
12
  /** Undefined means unavailable, null means no image; transfer failures reject. */
13
13
  getImage(): Promise<Uint8Array | null | undefined>;
14
+ /** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */
15
+ getFilePaths?(): Promise<string[] | null | undefined>;
14
16
  /** Linux uses command-line tools to retain clipboard ownership instead. */
15
17
  setText?(text: string): Promise<void>;
16
18
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Oklab and OKHSL <-> sRGB conversion. `colors.ts` builds its OKLCH, OKHSL, and color mixing on it.
3
+ *
4
+ * Oklab and OKHSL are Björn Ottosson's color spaces; OKHSL's saturation is relative to the sRGB gamut at
5
+ * each hue and lightness. This is a port of his reference implementation (https://bottosson.github.io/posts/colorpicker/),
6
+ * Copyright (c) 2021 Björn Ottosson, used under the MIT license:
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
9
+ * associated documentation files (the "Software"), to deal in the Software without restriction, including
10
+ * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
12
+ * following conditions: The above copyright notice and this permission notice shall be included in all
13
+ * copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
14
+ * KIND, EXPRESS OR IMPLIED.
15
+ */
16
+
17
+ import type { RgbColor } from "./terminal-colors.ts";
18
+
19
+ type Vector = [number, number, number];
20
+ type Matrix = [Vector, Vector, Vector];
21
+
22
+ const multiply = (m: Matrix, [x, y, z]: Vector): Vector =>
23
+ m.map((row) => row[0] * x + row[1] * y + row[2] * z) as Vector;
24
+
25
+ // ============================================================================
26
+ // OKHSL <-> sRGB
27
+ // ============================================================================
28
+
29
+ const LINEAR_SRGB_TO_LMS: Matrix = [
30
+ [0.4122214694707629, 0.5363325372617349, 0.0514459932675022],
31
+ [0.2119034958178251, 0.6806995506452344, 0.1073969535369405],
32
+ [0.0883024591900564, 0.2817188391361215, 0.6299787016738222],
33
+ ];
34
+ const LMS_TO_LAB: Matrix = [
35
+ [0.210454268309314, 0.793617774702305, -0.0040720430116193],
36
+ [1.9779985324311684, -2.42859224204858, 0.450593709617411],
37
+ [0.0259040424655478, 0.7827717124575296, -0.8086757549230774],
38
+ ];
39
+ const LAB_TO_LMS: Matrix = [
40
+ [1, 0.3963377773761749, 0.2158037573099136],
41
+ [1, -0.1055613458156586, -0.0638541728258133],
42
+ [1, -0.0894841775298119, -1.2914855480194092],
43
+ ];
44
+ const LMS_TO_LINEAR_SRGB: Matrix = [
45
+ [4.0767416360759583, -3.3077115392580629, 0.2309699031821043],
46
+ [-1.2684379732850315, 2.6097573492876882, -0.341319376002657],
47
+ [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117],
48
+ ];
49
+ /**
50
+ * Per sRGB channel (red, green, blue): the (a, b) half-plane where that channel clips
51
+ * first, and the polynomial approximating the maximum saturation there.
52
+ */
53
+ const SATURATION_FIT: [[number, number], number[]][] = [
54
+ [
55
+ [-1.8817031, -0.80936501],
56
+ [1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245],
57
+ ],
58
+ [
59
+ [1.8144408, -1.19445267],
60
+ [0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204],
61
+ ],
62
+ [
63
+ [0.13110758, 1.81333971],
64
+ [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167],
65
+ ],
66
+ ];
67
+ const K1 = 0.206;
68
+ const K2 = 0.03;
69
+ const K3 = (1 + K1) / (1 + K2);
70
+
71
+ /** Oklab lightness to OKHSL lightness. */
72
+ export const oklabToOkhslLightness = (x: number): number =>
73
+ 0.5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) ** 2 + 4 * K2 * K3 * x));
74
+ /** OKHSL lightness to Oklab lightness. */
75
+ const okhslToOklabLightness = (x: number): number => (x * x + K1 * x) / (K3 * (x + K2));
76
+
77
+ /** sRGB transfer function: linear to encoded channel, both 0-1. */
78
+ const linearToSrgb = (value: number): number =>
79
+ value > 0.0031308 ? 1.055 * value ** (1 / 2.4) - 0.055 : 12.92 * value;
80
+ /** Inverse sRGB transfer function: encoded to linear channel, both 0-1. */
81
+ const srgbToLinear = (value: number): number => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
82
+
83
+ /** Oklab [L, a, b] to linear sRGB [r, g, b] (0-1, may leave the gamut). */
84
+ export function oklabToLinearSrgb(lab: Vector): Vector {
85
+ return multiply(LMS_TO_LINEAR_SRGB, multiply(LAB_TO_LMS, lab).map((value) => value ** 3) as Vector);
86
+ }
87
+
88
+ /** Linear sRGB [r, g, b] (0-1) to Oklab [L, a, b]. */
89
+ function linearSrgbToOklab(rgb: Vector): Vector {
90
+ return multiply(LMS_TO_LAB, multiply(LINEAR_SRGB_TO_LMS, rgb).map(Math.cbrt) as Vector);
91
+ }
92
+
93
+ /** sRGB channels (0-255) to Oklab [L, a, b]. */
94
+ export function rgbToOklab({ r, g, b }: RgbColor): Vector {
95
+ return linearSrgbToOklab([r / 255, g / 255, b / 255].map(srgbToLinear) as Vector);
96
+ }
97
+
98
+ /** Linear sRGB [r, g, b] to sRGB channels (0-255, rounded), clipping out-of-gamut channels. */
99
+ export function linearSrgbToRgb(linear: Vector): RgbColor {
100
+ const [r, g, b] = linear.map((value) => Math.round(Math.min(1, Math.max(0, linearToSrgb(value))) * 255));
101
+ return { r, g, b };
102
+ }
103
+
104
+ /** Rate of change of each cube-root LMS component along a chroma direction (a, b). */
105
+ function lmsSlopes(a: number, b: number): Vector {
106
+ return [LAB_TO_LMS[0], LAB_TO_LMS[1], LAB_TO_LMS[2]].map((row) => row[1] * a + row[2] * b) as Vector;
107
+ }
108
+
109
+ /** Largest saturation (C/L) inside sRGB for hue (a, b): polynomial fit plus one Halley step. */
110
+ function maxSaturation(a: number, b: number): number {
111
+ const channel = SATURATION_FIT.findIndex(([[x, y]], index) => index === 2 || x * a + y * b > 1);
112
+ const [k0, k1, k2, k3, k4] = SATURATION_FIT[channel][1];
113
+ const weights = LMS_TO_LINEAR_SRGB[channel];
114
+ const saturation = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
115
+
116
+ const slopes = lmsSlopes(a, b);
117
+ const base = slopes.map((k) => 1 + saturation * k);
118
+ const dot = (values: number[]) => values.reduce((sum, value, index) => sum + weights[index] * value, 0);
119
+ const f = dot(base.map((value) => value ** 3));
120
+ const f1 = dot(base.map((value, index) => 3 * slopes[index] * value ** 2));
121
+ const f2 = dot(base.map((value, index) => 6 * slopes[index] ** 2 * value));
122
+ return saturation - (f * f1) / (f1 * f1 - 0.5 * f * f2);
123
+ }
124
+
125
+ /** Oklab lightness and chroma of the most saturated sRGB color of hue (a, b). */
126
+ function cusp(a: number, b: number): [number, number] {
127
+ const saturation = maxSaturation(a, b);
128
+ const lightness = Math.cbrt(1 / Math.max(...oklabToLinearSrgb([1, saturation * a, saturation * b])));
129
+ return [lightness, lightness * saturation];
130
+ }
131
+
132
+ /** Chroma where the constant-lightness line at `lightness` leaves the sRGB gamut. */
133
+ function maxChroma(a: number, b: number, lightness: number, [cuspL, cuspC]: [number, number]): number {
134
+ if (lightness <= cuspL) return (cuspC * lightness) / cuspL;
135
+ // Upper half: triangle edge, then one Halley step against each channel reaching 1.
136
+ const t = (cuspC * (lightness - 1)) / (cuspL - 1);
137
+ const slopes = lmsSlopes(a, b);
138
+ const lms = slopes.map((k) => lightness + t * k);
139
+ const cubes = lms.map((value) => value ** 3);
140
+ const first = lms.map((value, index) => 3 * slopes[index] * value ** 2);
141
+ const second = lms.map((value, index) => 6 * slopes[index] ** 2 * value);
142
+ const dot = (row: Vector, values: number[]) => row[0] * values[0] + row[1] * values[1] + row[2] * values[2];
143
+ const steps = LMS_TO_LINEAR_SRGB.map((row) => {
144
+ const f = dot(row, cubes) - 1;
145
+ const f1 = dot(row, first);
146
+ const f2 = dot(row, second);
147
+ const u = f1 / (f1 * f1 - 0.5 * f * f2);
148
+ return u >= 0 ? -f * u : Number.MAX_VALUE;
149
+ });
150
+ return t + Math.min(...steps);
151
+ }
152
+
153
+ /** OKHSL's chroma reference points at lightness L and hue (a, b): [c0, cMid, cMax]. */
154
+ function chromaStops(L: number, a: number, b: number): [number, number, number] {
155
+ const peak = cusp(a, b);
156
+ const cMax = maxChroma(a, b, L, peak);
157
+ const k = cMax / Math.min(L * (peak[1] / peak[0]), (1 - L) * (peak[1] / (1 - peak[0])));
158
+ const midS =
159
+ 0.11516993 +
160
+ 1 /
161
+ (7.4477897 +
162
+ 4.1590124 * b +
163
+ a *
164
+ (-2.19557347 +
165
+ 1.75198401 * b +
166
+ a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));
167
+ const midT =
168
+ 0.11239642 +
169
+ 1 /
170
+ (1.6132032 -
171
+ 0.68124379 * b +
172
+ a *
173
+ (0.40370612 +
174
+ 0.90148123 * b +
175
+ a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));
176
+ const cMid = 0.9 * k * Math.sqrt(Math.sqrt(1 / (1 / (L * midS) ** 4 + 1 / ((1 - L) * midT) ** 4)));
177
+ const c0 = Math.sqrt(1 / (1 / (L * 0.4) ** 2 + 1 / ((1 - L) * 0.8) ** 2));
178
+ return [c0, cMid, cMax];
179
+ }
180
+
181
+ /**
182
+ * Convert OKHSL to sRGB channels (0-255, rounded), clipping out-of-gamut channels.
183
+ * @param hue Hue in degrees.
184
+ * @param saturation Saturation, 0-1.
185
+ * @param lightness Lightness, 0-1.
186
+ */
187
+ export function okhslToRgb(hue: number, saturation: number, lightness: number): RgbColor {
188
+ const L = okhslToOklabLightness(lightness);
189
+ let lab: Vector = [L, 0, 0];
190
+ if (L > 0 && L < 1 && saturation > 0) {
191
+ const angle = (2 * Math.PI * (((hue % 360) + 360) % 360)) / 360;
192
+ const a = Math.cos(angle);
193
+ const b = Math.sin(angle);
194
+ const [c0, cMid, cMax] = chromaStops(L, a, b);
195
+ // Chroma rises from 0 through cMid at s = 0.8 to cMax at s = 1.
196
+ let chroma: number;
197
+ if (saturation < 0.8) {
198
+ const t = 1.25 * saturation;
199
+ const k1 = 0.8 * c0;
200
+ chroma = (t * k1) / (1 - (1 - k1 / cMid) * t);
201
+ } else {
202
+ const t = 5 * (saturation - 0.8);
203
+ const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0;
204
+ chroma = cMid + (t * k1) / (1 - (1 - k1 / (cMax - cMid)) * t);
205
+ }
206
+ lab = [L, chroma * a, chroma * b];
207
+ }
208
+ return linearSrgbToRgb(oklabToLinearSrgb(lab));
209
+ }
210
+
211
+ /**
212
+ * Convert sRGB channels (0-255) to OKHSL.
213
+ * @returns Hue `h` in degrees (0 for grays), saturation `s` and lightness `l` 0-1.
214
+ */
215
+ export function rgbToOkhsl(rgb: RgbColor): { h: number; s: number; l: number } {
216
+ const [L, labA, labB] = rgbToOklab(rgb);
217
+ const chroma = Math.hypot(labA, labB);
218
+ const lightness = oklabToOkhslLightness(L);
219
+ if (chroma < 1e-9 || lightness <= 0 || lightness >= 1) return { h: 0, s: 0, l: lightness };
220
+
221
+ const hue = ((Math.atan2(labB, labA) * 180) / Math.PI + 360) % 360;
222
+ const [c0, cMid, cMax] = chromaStops(L, labA / chroma, labB / chroma);
223
+ let saturation: number;
224
+ if (chroma < cMid) {
225
+ const k1 = 0.8 * c0;
226
+ saturation = 0.8 * (chroma / (k1 + (1 - k1 / cMid) * chroma));
227
+ } else {
228
+ const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0;
229
+ const offset = chroma - cMid;
230
+ saturation = 0.8 + 0.2 * (offset / (k1 + (1 - k1 / (cMax - cMid)) * offset));
231
+ }
232
+ return { h: hue, s: Math.min(1, Math.max(0, saturation)), l: lightness };
233
+ }
@@ -6,6 +6,16 @@ export interface RgbColor {
6
6
 
7
7
  export type TerminalColorScheme = "dark" | "light";
8
8
 
9
+ /** Colors the terminal reports for its current theme. */
10
+ export interface TerminalColors {
11
+ /** Default foreground (OSC 10). */
12
+ foreground?: RgbColor;
13
+ /** Default background (OSC 11). */
14
+ background?: RgbColor;
15
+ /** ANSI colors 0-15 (OSC 4). Only set when the terminal reported all 16. */
16
+ palette?: RgbColor[];
17
+ }
18
+
9
19
  function hexToRgb(hex: string): RgbColor {
10
20
  const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
11
21
  const r = parseInt(normalized.slice(0, 2), 16);
@@ -25,20 +35,28 @@ function parseOscHexChannel(channel: string): number | undefined {
25
35
  return Math.round((parseInt(channel, 16) / max) * 255);
26
36
  }
27
37
 
28
- const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
29
- const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/;
38
+ /** What an OSC color reply reports: the default foreground (OSC 10), background (OSC 11), or a palette index (OSC 4). */
39
+ export type OscColorTarget = "foreground" | "background" | number;
30
40
 
31
- export function isOsc11BackgroundColorResponse(data: string): boolean {
32
- return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
33
- }
41
+ const OSC_COLOR_RESPONSE_PATTERN = /^\x1b\](?:(1[01])|4;(\d{1,3}));([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
42
+ const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/;
34
43
 
35
- export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
36
- const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN);
44
+ /**
45
+ * Parse an OSC 10, 11, or 4 color reply. Returns undefined when `data` is not such a reply;
46
+ * `rgb` is undefined when it is a reply with an unparseable color.
47
+ */
48
+ export function parseOscColorResponse(data: string): { target: OscColorTarget; rgb: RgbColor | undefined } | undefined {
49
+ const match = data.match(OSC_COLOR_RESPONSE_PATTERN);
37
50
  if (!match) {
38
51
  return undefined;
39
52
  }
53
+ const target: OscColorTarget =
54
+ match[1] === "10" ? "foreground" : match[1] === "11" ? "background" : Number.parseInt(match[2], 10);
55
+ return { target, rgb: parseOscColorValue(match[3]) };
56
+ }
40
57
 
41
- const value = match[1].trim();
58
+ function parseOscColorValue(rawValue: string): RgbColor | undefined {
59
+ const value = rawValue.trim();
42
60
  if (value.startsWith("#")) {
43
61
  const hex = value.slice(1);
44
62
  if (/^[0-9a-f]{6}$/i.test(hex)) {
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import type { TerminalColorMode } from "./colors.ts";
5
6
 
6
7
  export type ImageProtocol = "kitty" | "iterm2" | null;
7
8
 
@@ -71,7 +72,7 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean)
71
72
  const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
72
73
  const term = process.env.TERM?.toLowerCase() || "";
73
74
  const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
74
- const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
75
+ const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit" || term.endsWith("-direct");
75
76
  const isWindowsConsole = process.platform === "win32";
76
77
 
77
78
  // Emit OSC 8 hyperlinks only when tmux confirms it forwards.
@@ -168,6 +169,10 @@ export function getCapabilities(): TerminalCapabilities {
168
169
  return cachedCapabilities;
169
170
  }
170
171
 
172
+ export function getTerminalColorMode(capabilities: TerminalCapabilities = getCapabilities()): TerminalColorMode {
173
+ return capabilities.trueColor ? "truecolor" : "256color";
174
+ }
175
+
171
176
  export function resetCapabilitiesCache(): void {
172
177
  cachedCapabilities = null;
173
178
  }
@@ -432,11 +437,21 @@ export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows
432
437
  return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
433
438
  }
434
439
 
440
+ function chooseLessDistortedCellCount(upperCount: number, idealCount: number): number {
441
+ if (upperCount <= 1) return upperCount;
442
+
443
+ const lowerCount = upperCount - 1;
444
+ const upperDistortion = Math.max(upperCount / idealCount, idealCount / upperCount);
445
+ const lowerDistortion = Math.max(lowerCount / idealCount, idealCount / lowerCount);
446
+ return lowerDistortion < upperDistortion ? lowerCount : upperCount;
447
+ }
448
+
435
449
  export function calculateImageCellSize(
436
450
  imageDimensions: ImageDimensions,
437
451
  maxWidthCells: number,
438
452
  maxHeightCells?: number,
439
453
  cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
454
+ optimizeAspectRatio = false,
440
455
  ): ImageCellSize {
441
456
  const maxWidth = Math.max(1, Math.floor(maxWidthCells));
442
457
  const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
@@ -449,13 +464,26 @@ export function calculateImageCellSize(
449
464
 
450
465
  const scaledWidthPx = imageWidth * scale;
451
466
  const scaledHeightPx = imageHeight * scale;
452
- const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
453
- const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
467
+ let columns = Math.max(1, Math.min(maxWidth, Math.ceil(scaledWidthPx / cellDimensions.widthPx)));
468
+ const heightRows = scaledHeightPx / cellDimensions.heightPx;
469
+ let rows = Math.max(1, Math.ceil(heightRows));
470
+ if (maxHeight !== undefined) {
471
+ rows = Math.min(maxHeight, rows);
472
+ }
454
473
 
455
- return {
456
- columns: Math.max(1, Math.min(maxWidth, columns)),
457
- rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
458
- };
474
+ if (!optimizeAspectRatio) {
475
+ return { columns, rows };
476
+ }
477
+
478
+ if (widthScale <= heightScale) {
479
+ const idealRows = (columns * cellDimensions.widthPx * imageHeight) / (imageWidth * cellDimensions.heightPx);
480
+ rows = chooseLessDistortedCellCount(rows, idealRows);
481
+ } else {
482
+ const idealColumns = (rows * cellDimensions.heightPx * imageWidth) / (imageHeight * cellDimensions.widthPx);
483
+ columns = chooseLessDistortedCellCount(columns, idealColumns);
484
+ }
485
+
486
+ return { columns, rows };
459
487
  }
460
488
 
461
489
  export function calculateImageRows(
@@ -619,7 +647,14 @@ export function renderImage(
619
647
  }
620
648
 
621
649
  const maxWidth = options.maxWidthCells ?? 80;
622
- const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions());
650
+ // Reduce Kitty's cell-aligned distortion without shrinking iTerm2 reservations.
651
+ const size = calculateImageCellSize(
652
+ imageDimensions,
653
+ maxWidth,
654
+ options.maxHeightCells,
655
+ getCellDimensions(),
656
+ caps.images === "kitty",
657
+ );
623
658
 
624
659
  if (caps.images === "kitty") {
625
660
  if (options.imageId !== undefined) {