@dungle-scrubs/tallow 0.8.21 → 0.8.23

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 (217) hide show
  1. package/dist/cli.js +35 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -0,0 +1,619 @@
1
+ export type ImageProtocol = "kitty" | "iterm2" | null;
2
+
3
+ export interface TerminalCapabilities {
4
+ images: ImageProtocol;
5
+ trueColor: boolean;
6
+ hyperlinks: boolean;
7
+ }
8
+
9
+ export interface CellDimensions {
10
+ widthPx: number;
11
+ heightPx: number;
12
+ }
13
+
14
+ export interface ImageDimensions {
15
+ widthPx: number;
16
+ heightPx: number;
17
+ }
18
+
19
+ export interface ImageRenderOptions {
20
+ maxWidthCells?: number;
21
+ maxHeightCells?: number;
22
+ preserveAspectRatio?: boolean;
23
+ /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
24
+ imageId?: number;
25
+ }
26
+
27
+ let cachedCapabilities: TerminalCapabilities | null = null;
28
+
29
+ // Default cell dimensions - updated by TUI when terminal responds to query
30
+ let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
31
+
32
+ export function getCellDimensions(): CellDimensions {
33
+ return cellDimensions;
34
+ }
35
+
36
+ export function setCellDimensions(dims: CellDimensions): void {
37
+ if (dims.widthPx > 0 && dims.heightPx > 0) {
38
+ cellDimensions = dims;
39
+ }
40
+ }
41
+
42
+ export function detectCapabilities(): TerminalCapabilities {
43
+ const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
44
+ const term = process.env.TERM?.toLowerCase() || "";
45
+ const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
46
+
47
+ if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
48
+ return { images: "kitty", trueColor: true, hyperlinks: true };
49
+ }
50
+
51
+ if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
52
+ return { images: "kitty", trueColor: true, hyperlinks: true };
53
+ }
54
+
55
+ if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
56
+ return { images: "kitty", trueColor: true, hyperlinks: true };
57
+ }
58
+
59
+ if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
60
+ return { images: "iterm2", trueColor: true, hyperlinks: true };
61
+ }
62
+
63
+ if (termProgram === "vscode") {
64
+ return { images: null, trueColor: true, hyperlinks: true };
65
+ }
66
+
67
+ if (termProgram === "alacritty") {
68
+ return { images: null, trueColor: true, hyperlinks: true };
69
+ }
70
+
71
+ const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
72
+ return { images: null, trueColor, hyperlinks: true };
73
+ }
74
+
75
+ export function getCapabilities(): TerminalCapabilities {
76
+ if (!cachedCapabilities) {
77
+ cachedCapabilities = detectCapabilities();
78
+ }
79
+ return cachedCapabilities;
80
+ }
81
+
82
+ export function resetCapabilitiesCache(): void {
83
+ cachedCapabilities = null;
84
+ }
85
+
86
+ const KITTY_PREFIX = "\x1b_G";
87
+ const ITERM2_PREFIX = "\x1b]1337;File=";
88
+
89
+ export function isImageLine(line: string): boolean {
90
+ // Fast path: sequence at line start (single-row images)
91
+ if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
92
+ return true;
93
+ }
94
+ // Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
95
+ return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
96
+ }
97
+
98
+ /**
99
+ * Generate a random image ID for Kitty graphics protocol.
100
+ * Uses random IDs to avoid collisions between different module instances
101
+ * (e.g., main app vs extensions).
102
+ */
103
+ export function allocateImageId(): number {
104
+ // Use random ID in range [1, 0xffffffff] to avoid collisions
105
+ return Math.floor(Math.random() * 0xfffffffe) + 1;
106
+ }
107
+
108
+ export function encodeKitty(
109
+ base64Data: string,
110
+ options: {
111
+ columns?: number;
112
+ rows?: number;
113
+ imageId?: number;
114
+ } = {}
115
+ ): string {
116
+ const CHUNK_SIZE = 4096;
117
+
118
+ const params: string[] = ["a=T", "f=100", "q=2"];
119
+
120
+ if (options.columns) params.push(`c=${options.columns}`);
121
+ if (options.rows) params.push(`r=${options.rows}`);
122
+ if (options.imageId) params.push(`i=${options.imageId}`);
123
+
124
+ if (base64Data.length <= CHUNK_SIZE) {
125
+ return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
126
+ }
127
+
128
+ const chunks: string[] = [];
129
+ let offset = 0;
130
+ let isFirst = true;
131
+
132
+ while (offset < base64Data.length) {
133
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
134
+ const isLast = offset + CHUNK_SIZE >= base64Data.length;
135
+
136
+ if (isFirst) {
137
+ chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
138
+ isFirst = false;
139
+ } else if (isLast) {
140
+ chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
141
+ } else {
142
+ chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
143
+ }
144
+
145
+ offset += CHUNK_SIZE;
146
+ }
147
+
148
+ return chunks.join("");
149
+ }
150
+
151
+ /**
152
+ * Delete a Kitty graphics image by ID.
153
+ * Uses uppercase 'I' to also free the image data.
154
+ */
155
+ export function deleteKittyImage(imageId: number): string {
156
+ return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
157
+ }
158
+
159
+ /**
160
+ * Delete all visible Kitty graphics images.
161
+ * Uses uppercase 'A' to also free the image data.
162
+ */
163
+ export function deleteAllKittyImages(): string {
164
+ return `\x1b_Ga=d,d=A\x1b\\`;
165
+ }
166
+
167
+ export function encodeITerm2(
168
+ base64Data: string,
169
+ options: {
170
+ width?: number | string;
171
+ height?: number | string;
172
+ name?: string;
173
+ preserveAspectRatio?: boolean;
174
+ inline?: boolean;
175
+ } = {}
176
+ ): string {
177
+ const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
178
+
179
+ if (options.width !== undefined) params.push(`width=${options.width}`);
180
+ if (options.height !== undefined) params.push(`height=${options.height}`);
181
+ if (options.name) {
182
+ const nameBase64 = Buffer.from(options.name).toString("base64");
183
+ params.push(`name=${nameBase64}`);
184
+ }
185
+ if (options.preserveAspectRatio === false) {
186
+ params.push("preserveAspectRatio=0");
187
+ }
188
+
189
+ return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
190
+ }
191
+
192
+ /** Layout dimensions for a rendered image in terminal cells. */
193
+ export interface ImageLayout {
194
+ /** Number of terminal rows the image occupies. */
195
+ rows: number;
196
+ /** Number of terminal columns the image occupies. */
197
+ columns: number;
198
+ }
199
+
200
+ /**
201
+ * Pick the closest valid integer terminal cell count for a fractional target.
202
+ *
203
+ * @param target - Ideal (fractional) cell count
204
+ * @param min - Minimum allowed cell count (inclusive)
205
+ * @param max - Maximum allowed cell count (inclusive)
206
+ * @returns Closest in-range integer cell count
207
+ */
208
+ function closestCellCount(target: number, min: number, max: number): number {
209
+ const lower = Math.max(min, Math.min(max, Math.floor(target)));
210
+ const upper = Math.max(min, Math.min(max, Math.ceil(target)));
211
+ if (lower === upper) {
212
+ return lower;
213
+ }
214
+ return Math.abs(target - upper) < Math.abs(target - lower) ? upper : lower;
215
+ }
216
+
217
+ /**
218
+ * Calculate the cell layout for an image at a given max width.
219
+ * Clamps to the image's natural pixel width (prevents upscaling),
220
+ * then optionally clamps height and back-calculates width to preserve aspect ratio.
221
+ *
222
+ * Uses nearest-integer quantization instead of always rounding up to reduce
223
+ * narrow-width aspect distortion.
224
+ *
225
+ * @param imageDimensions - Native pixel dimensions of the image
226
+ * @param maxWidthCells - Maximum column count for the image
227
+ * @param cellDims - Terminal cell pixel dimensions
228
+ * @param maxHeightCells - Optional row cap (portrait images)
229
+ * @returns Layout with rows and columns the image will occupy
230
+ */
231
+ export function calculateImageLayout(
232
+ imageDimensions: ImageDimensions,
233
+ maxWidthCells: number,
234
+ cellDims: CellDimensions = { widthPx: 9, heightPx: 18 },
235
+ maxHeightCells?: number
236
+ ): ImageLayout {
237
+ const safeImageWidthPx = Math.max(1, imageDimensions.widthPx);
238
+ const safeImageHeightPx = Math.max(1, imageDimensions.heightPx);
239
+ const safeCellWidthPx = Math.max(1, cellDims.widthPx);
240
+ const safeCellHeightPx = Math.max(1, cellDims.heightPx);
241
+ const safeMaxWidthCells = Math.max(1, Math.floor(maxWidthCells));
242
+ const safeMaxHeightCells =
243
+ maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
244
+
245
+ // Clamp to natural width — prevents upscaling small images.
246
+ const naturalCols = Math.max(1, Math.ceil(safeImageWidthPx / safeCellWidthPx));
247
+ const maxColumns = Math.min(safeMaxWidthCells, naturalCols);
248
+ let columns = maxColumns;
249
+
250
+ const idealRows =
251
+ (columns * safeCellWidthPx * safeImageHeightPx) / (safeImageWidthPx * safeCellHeightPx);
252
+ let rows = closestCellCount(idealRows, 1, Number.MAX_SAFE_INTEGER);
253
+
254
+ // When height-clamped, reduce columns proportionally to preserve aspect ratio.
255
+ // Without this, the terminal receives a wide column count but the text layer
256
+ // only reserves maxHeightCells rows, causing the image to overflow or squish.
257
+ if (safeMaxHeightCells !== undefined && rows > safeMaxHeightCells) {
258
+ rows = safeMaxHeightCells;
259
+ const idealColumns =
260
+ (rows * safeCellHeightPx * safeImageWidthPx) / (safeImageHeightPx * safeCellWidthPx);
261
+ columns = closestCellCount(idealColumns, 1, maxColumns);
262
+ }
263
+
264
+ return { columns: Math.max(1, columns), rows: Math.max(1, rows) };
265
+ }
266
+
267
+ export function getPngDimensions(base64Data: string): ImageDimensions | null {
268
+ try {
269
+ const buffer = Buffer.from(base64Data, "base64");
270
+
271
+ if (buffer.length < 24) {
272
+ return null;
273
+ }
274
+
275
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
276
+ return null;
277
+ }
278
+
279
+ const width = buffer.readUInt32BE(16);
280
+ const height = buffer.readUInt32BE(20);
281
+
282
+ return { widthPx: width, heightPx: height };
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ export function getJpegDimensions(base64Data: string): ImageDimensions | null {
289
+ try {
290
+ const buffer = Buffer.from(base64Data, "base64");
291
+
292
+ if (buffer.length < 2) {
293
+ return null;
294
+ }
295
+
296
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
297
+ return null;
298
+ }
299
+
300
+ let offset = 2;
301
+ while (offset < buffer.length - 9) {
302
+ if (buffer[offset] !== 0xff) {
303
+ offset++;
304
+ continue;
305
+ }
306
+
307
+ const marker = buffer[offset + 1];
308
+
309
+ if (marker >= 0xc0 && marker <= 0xc2) {
310
+ const height = buffer.readUInt16BE(offset + 5);
311
+ const width = buffer.readUInt16BE(offset + 7);
312
+ return { widthPx: width, heightPx: height };
313
+ }
314
+
315
+ if (offset + 3 >= buffer.length) {
316
+ return null;
317
+ }
318
+ const length = buffer.readUInt16BE(offset + 2);
319
+ if (length < 2) {
320
+ return null;
321
+ }
322
+ offset += 2 + length;
323
+ }
324
+
325
+ return null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ export function getGifDimensions(base64Data: string): ImageDimensions | null {
332
+ try {
333
+ const buffer = Buffer.from(base64Data, "base64");
334
+
335
+ if (buffer.length < 10) {
336
+ return null;
337
+ }
338
+
339
+ const sig = buffer.slice(0, 6).toString("ascii");
340
+ if (sig !== "GIF87a" && sig !== "GIF89a") {
341
+ return null;
342
+ }
343
+
344
+ const width = buffer.readUInt16LE(6);
345
+ const height = buffer.readUInt16LE(8);
346
+
347
+ return { widthPx: width, heightPx: height };
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ export function getWebpDimensions(base64Data: string): ImageDimensions | null {
354
+ try {
355
+ const buffer = Buffer.from(base64Data, "base64");
356
+
357
+ if (buffer.length < 30) {
358
+ return null;
359
+ }
360
+
361
+ const riff = buffer.slice(0, 4).toString("ascii");
362
+ const webp = buffer.slice(8, 12).toString("ascii");
363
+ if (riff !== "RIFF" || webp !== "WEBP") {
364
+ return null;
365
+ }
366
+
367
+ const chunk = buffer.slice(12, 16).toString("ascii");
368
+ if (chunk === "VP8 ") {
369
+ if (buffer.length < 30) return null;
370
+ const width = buffer.readUInt16LE(26) & 0x3fff;
371
+ const height = buffer.readUInt16LE(28) & 0x3fff;
372
+ return { widthPx: width, heightPx: height };
373
+ } else if (chunk === "VP8L") {
374
+ if (buffer.length < 25) return null;
375
+ const bits = buffer.readUInt32LE(21);
376
+ const width = (bits & 0x3fff) + 1;
377
+ const height = ((bits >> 14) & 0x3fff) + 1;
378
+ return { widthPx: width, heightPx: height };
379
+ } else if (chunk === "VP8X") {
380
+ if (buffer.length < 30) return null;
381
+ const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
382
+ const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
383
+ return { widthPx: width, heightPx: height };
384
+ }
385
+
386
+ return null;
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ /** Supported image format identifiers returned by `detectImageFormat`. */
393
+ export type ImageFormat = "png" | "jpeg" | "gif" | "webp";
394
+
395
+ /**
396
+ * Detect image format from file header bytes (magic numbers).
397
+ *
398
+ * Checks the first few bytes of a buffer for known image signatures:
399
+ * - PNG: `89 50 4E 47 0D 0A 1A 0A` (8 bytes)
400
+ * - JPEG: `FF D8 FF` (3 bytes)
401
+ * - GIF: `47 49 46 38` + `37`/`39` + `61` (GIF87a / GIF89a)
402
+ * - WebP: `52 49 46 46 ... 57 45 42 50` (RIFF at 0-3, WEBP at 8-11)
403
+ *
404
+ * @param buffer - Raw file bytes (only first 12 bytes are inspected)
405
+ * @returns Detected format string, or null if not a recognized image
406
+ */
407
+ export function detectImageFormat(buffer: Buffer): ImageFormat | null {
408
+ if (buffer.length < 3) return null;
409
+
410
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
411
+ if (
412
+ buffer.length >= 8 &&
413
+ buffer[0] === 0x89 &&
414
+ buffer[1] === 0x50 &&
415
+ buffer[2] === 0x4e &&
416
+ buffer[3] === 0x47 &&
417
+ buffer[4] === 0x0d &&
418
+ buffer[5] === 0x0a &&
419
+ buffer[6] === 0x1a &&
420
+ buffer[7] === 0x0a
421
+ ) {
422
+ return "png";
423
+ }
424
+
425
+ // JPEG: FF D8 FF
426
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
427
+ return "jpeg";
428
+ }
429
+
430
+ // GIF: 47 49 46 38 (37|39) 61
431
+ if (
432
+ buffer.length >= 6 &&
433
+ buffer[0] === 0x47 &&
434
+ buffer[1] === 0x49 &&
435
+ buffer[2] === 0x46 &&
436
+ buffer[3] === 0x38 &&
437
+ (buffer[4] === 0x37 || buffer[4] === 0x39) &&
438
+ buffer[5] === 0x61
439
+ ) {
440
+ return "gif";
441
+ }
442
+
443
+ // WebP: RIFF....WEBP (bytes 0-3 = "RIFF", bytes 8-11 = "WEBP")
444
+ if (
445
+ buffer.length >= 12 &&
446
+ buffer[0] === 0x52 &&
447
+ buffer[1] === 0x49 &&
448
+ buffer[2] === 0x46 &&
449
+ buffer[3] === 0x46 &&
450
+ buffer[8] === 0x57 &&
451
+ buffer[9] === 0x45 &&
452
+ buffer[10] === 0x42 &&
453
+ buffer[11] === 0x50
454
+ ) {
455
+ return "webp";
456
+ }
457
+
458
+ return null;
459
+ }
460
+
461
+ /**
462
+ * Map an `ImageFormat` to its standard MIME type string.
463
+ *
464
+ * @param format - Detected image format
465
+ * @returns MIME type (e.g., `"image/png"`)
466
+ */
467
+ export function imageFormatToMime(format: ImageFormat): string {
468
+ switch (format) {
469
+ case "png":
470
+ return "image/png";
471
+ case "jpeg":
472
+ return "image/jpeg";
473
+ case "gif":
474
+ return "image/gif";
475
+ case "webp":
476
+ return "image/webp";
477
+ }
478
+ }
479
+
480
+ /** Metadata about an image's original and display dimensions after processing. */
481
+ export interface ImageMetadata {
482
+ /** Original pixel width before any resizing. */
483
+ readonly originalWidth: number;
484
+ /** Original pixel height before any resizing. */
485
+ readonly originalHeight: number;
486
+ /** Display pixel width after resizing (equals original if not resized). */
487
+ readonly displayWidth: number;
488
+ /** Display pixel height after resizing (equals original if not resized). */
489
+ readonly displayHeight: number;
490
+ /** Whether the image was resized from its original dimensions. */
491
+ readonly resized: boolean;
492
+ /** Detected image format (e.g., "png", "jpeg"). */
493
+ readonly format: ImageFormat | null;
494
+ /** File size in bytes, if known. */
495
+ readonly sizeBytes?: number;
496
+ }
497
+
498
+ /**
499
+ * Create an `ImageMetadata` object from original and display dimensions.
500
+ *
501
+ * Sets `resized` automatically by comparing original and display dimensions.
502
+ *
503
+ * @param original - Original pixel dimensions before resizing
504
+ * @param display - Display pixel dimensions after resizing
505
+ * @param format - Detected image format, or null if unknown
506
+ * @param sizeBytes - File size in bytes (optional)
507
+ * @returns Populated ImageMetadata
508
+ */
509
+ export function createImageMetadata(
510
+ original: ImageDimensions,
511
+ display: ImageDimensions,
512
+ format: ImageFormat | null,
513
+ sizeBytes?: number
514
+ ): ImageMetadata {
515
+ return {
516
+ originalWidth: original.widthPx,
517
+ originalHeight: original.heightPx,
518
+ displayWidth: display.widthPx,
519
+ displayHeight: display.heightPx,
520
+ resized: original.widthPx !== display.widthPx || original.heightPx !== display.heightPx,
521
+ format,
522
+ sizeBytes,
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Format a compact dimension string for display.
528
+ *
529
+ * - Not resized: `"1920×1080"`
530
+ * - Resized: `"3840×2160 → 1920×1080"`
531
+ *
532
+ * @param meta - Image metadata with dimension info
533
+ * @returns Formatted dimension string
534
+ */
535
+ export function formatImageDimensions(meta: ImageMetadata): string {
536
+ if (meta.resized) {
537
+ return `${meta.originalWidth}×${meta.originalHeight} → ${meta.displayWidth}×${meta.displayHeight}`;
538
+ }
539
+ return `${meta.originalWidth}×${meta.originalHeight}`;
540
+ }
541
+
542
+ export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
543
+ if (mimeType === "image/png") {
544
+ return getPngDimensions(base64Data);
545
+ }
546
+ if (mimeType === "image/jpeg") {
547
+ return getJpegDimensions(base64Data);
548
+ }
549
+ if (mimeType === "image/gif") {
550
+ return getGifDimensions(base64Data);
551
+ }
552
+ if (mimeType === "image/webp") {
553
+ return getWebpDimensions(base64Data);
554
+ }
555
+ return null;
556
+ }
557
+
558
+ /**
559
+ * Render an image as a terminal escape sequence (Kitty or iTerm2 protocol).
560
+ * Returns the escape sequence, the number of rows/columns it occupies, and
561
+ * an optional Kitty image ID for later cleanup.
562
+ *
563
+ * @param base64Data - Base64-encoded image data
564
+ * @param imageDimensions - Native pixel dimensions
565
+ * @param options - Width/height caps, aspect ratio, image ID
566
+ * @returns Rendered sequence with layout info, or null if unsupported
567
+ */
568
+ export function renderImage(
569
+ base64Data: string,
570
+ imageDimensions: ImageDimensions,
571
+ options: ImageRenderOptions = {}
572
+ ): { sequence: string; rows: number; columns: number; imageId?: number } | null {
573
+ const caps = getCapabilities();
574
+
575
+ if (!caps.images) {
576
+ return null;
577
+ }
578
+
579
+ const maxWidth = options.maxWidthCells ?? 80;
580
+ const layout = calculateImageLayout(
581
+ imageDimensions,
582
+ maxWidth,
583
+ getCellDimensions(),
584
+ options.maxHeightCells
585
+ );
586
+
587
+ if (caps.images === "kitty") {
588
+ // Omit rows — let Kitty auto-calculate from the image's native aspect ratio.
589
+ // This avoids rounding errors in our row calculation that cause subtle stretching.
590
+ const sequence = encodeKitty(base64Data, {
591
+ columns: layout.columns,
592
+ imageId: options.imageId,
593
+ });
594
+ return { sequence, rows: layout.rows, columns: layout.columns, imageId: options.imageId };
595
+ }
596
+
597
+ if (caps.images === "iterm2") {
598
+ const sequence = encodeITerm2(base64Data, {
599
+ width: layout.columns,
600
+ height: "auto",
601
+ preserveAspectRatio: options.preserveAspectRatio ?? true,
602
+ });
603
+ return { sequence, rows: layout.rows, columns: layout.columns };
604
+ }
605
+
606
+ return null;
607
+ }
608
+
609
+ export function imageFallback(
610
+ mimeType: string,
611
+ dimensions?: ImageDimensions,
612
+ filename?: string
613
+ ): string {
614
+ const parts: string[] = [];
615
+ if (filename) parts.push(filename);
616
+ parts.push(`[${mimeType}]`);
617
+ if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
618
+ return `[Image: ${parts.join(" ")}]`;
619
+ }