@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,363 @@
1
+ /**
2
+ * Tests for image layout calculations: maxHeightCells clamping,
3
+ * natural width clamping, and aspect ratio preservation.
4
+ */
5
+ import { describe, expect, it } from "bun:test";
6
+ import {
7
+ calculateImageLayout,
8
+ createImageMetadata,
9
+ detectImageFormat,
10
+ formatImageDimensions,
11
+ type ImageDimensions,
12
+ imageFormatToMime,
13
+ renderImage,
14
+ } from "../terminal-image.js";
15
+ import { withCapabilityEnv } from "../test-utils/capability-env.js";
16
+
17
+ const DEFAULT_CELL = { widthPx: 9, heightPx: 18 };
18
+
19
+ /**
20
+ * Measure relative aspect-ratio error between source image and cell layout.
21
+ *
22
+ * @param image - Source image dimensions in pixels
23
+ * @param columns - Rendered terminal columns
24
+ * @param rows - Rendered terminal rows
25
+ * @returns Relative error (0 = exact, 0.1 = 10% off)
26
+ */
27
+ function getAspectError(image: ImageDimensions, columns: number, rows: number): number {
28
+ const sourceAspect = image.widthPx / image.heightPx;
29
+ const renderedAspect = (columns * DEFAULT_CELL.widthPx) / (rows * DEFAULT_CELL.heightPx);
30
+ return Math.abs(renderedAspect / sourceAspect - 1);
31
+ }
32
+
33
+ // ── calculateImageLayout ─────────────────────────────────────────────────────
34
+
35
+ describe("calculateImageLayout", () => {
36
+ describe("basic layout", () => {
37
+ it("calculates rows and columns for a standard image", () => {
38
+ const dims: ImageDimensions = { widthPx: 1800, heightPx: 900 };
39
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
40
+ expect(layout.columns).toBe(60);
41
+ // 60 cols × 9px = 540px wide, scale = 540/1800 = 0.3
42
+ // 900 × 0.3 = 270px tall, 270/18 = 15 rows
43
+ expect(layout.rows).toBe(15);
44
+ });
45
+
46
+ it("returns at least 1 row for tiny images", () => {
47
+ const dims: ImageDimensions = { widthPx: 100, heightPx: 1 };
48
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
49
+ expect(layout.rows).toBeGreaterThanOrEqual(1);
50
+ });
51
+ });
52
+
53
+ describe("natural width clamping", () => {
54
+ it("clamps small images to their natural column count", () => {
55
+ const dims: ImageDimensions = { widthPx: 100, heightPx: 100 };
56
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
57
+ // Natural cols = ceil(100/9) = 12 — should NOT stretch to 60
58
+ expect(layout.columns).toBe(12);
59
+ expect(layout.columns).toBeLessThan(60);
60
+ });
61
+
62
+ it("does not clamp images wider than maxWidth", () => {
63
+ const dims: ImageDimensions = { widthPx: 3000, heightPx: 2000 };
64
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
65
+ expect(layout.columns).toBe(60);
66
+ });
67
+
68
+ it("uses natural width when it equals maxWidth", () => {
69
+ // 540px / 9px = 60 cols exactly
70
+ const dims: ImageDimensions = { widthPx: 540, heightPx: 270 };
71
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
72
+ expect(layout.columns).toBe(60);
73
+ });
74
+ });
75
+
76
+ describe("maxHeightCells clamping", () => {
77
+ it("reduces columns proportionally when height-clamped (portrait)", () => {
78
+ const dims: ImageDimensions = { widthPx: 1000, heightPx: 2000 };
79
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
80
+ expect(layout.rows).toBe(25);
81
+ // Unclamped: 60 cols → rows = ceil(2000*(540/1000)/18) = 60
82
+ // Clamped: heightScale = (25*18)/2000 = 0.225, cols = floor(1000*0.225/9) = 25
83
+ expect(layout.columns).toBe(25);
84
+ });
85
+
86
+ it("does not clamp landscape images below maxHeightCells", () => {
87
+ const dims: ImageDimensions = { widthPx: 2000, heightPx: 1000 };
88
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
89
+ // Landscape at 60 cols: rows = ceil(1000 * (540/2000) / 18) = 15
90
+ expect(layout.rows).toBe(15);
91
+ expect(layout.columns).toBe(60);
92
+ });
93
+
94
+ it("reduces columns proportionally when height-clamped (tall portrait)", () => {
95
+ const dims: ImageDimensions = { widthPx: 500, heightPx: 3000 };
96
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
97
+ expect(layout.rows).toBe(25);
98
+ // heightScale = 450/3000 = 0.15, cols = floor(500*0.15/9) = 8
99
+ expect(layout.columns).toBe(8);
100
+ });
101
+
102
+ it("reduces columns proportionally when height-clamped (square)", () => {
103
+ const dims: ImageDimensions = { widthPx: 1024, heightPx: 1024 };
104
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
105
+ expect(layout.rows).toBe(25);
106
+ // heightScale = 450/1024 = 0.4395, cols = floor(1024*0.4395/9) = 50
107
+ expect(layout.columns).toBe(50);
108
+ });
109
+
110
+ it("does nothing when maxHeightCells is undefined", () => {
111
+ const dims: ImageDimensions = { widthPx: 500, heightPx: 3000 };
112
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL);
113
+ // No clamp — natural cols = ceil(500/9) = 56, rows = ceil(3000*(56*9/500)/18) = 168
114
+ expect(layout.rows).toBeGreaterThan(100);
115
+ });
116
+
117
+ it("columns never exceed maxWidthCells", () => {
118
+ const dims: ImageDimensions = { widthPx: 4000, heightPx: 4001 };
119
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
120
+ expect(layout.columns).toBeLessThanOrEqual(60);
121
+ });
122
+
123
+ it("columns are at least 1 after clamping", () => {
124
+ // Extremely tall, narrow image
125
+ const dims: ImageDimensions = { widthPx: 10, heightPx: 10000 };
126
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 5);
127
+ expect(layout.columns).toBeGreaterThanOrEqual(1);
128
+ expect(layout.rows).toBe(5);
129
+ });
130
+ });
131
+
132
+ describe("combined natural width + height clamping", () => {
133
+ it("clamps width first, then height reduces columns further", () => {
134
+ // Small AND tall: 100×1000
135
+ const dims: ImageDimensions = { widthPx: 100, heightPx: 1000 };
136
+ const layout = calculateImageLayout(dims, 60, DEFAULT_CELL, 25);
137
+ // Natural cols = ceil(100/9) = 12 (clamped from 60)
138
+ // At 12 cols: rows = ceil(1000*(108/100)/18) = 60
139
+ // Clamped: heightScale = 450/1000 = 0.45, cols = floor(100*0.45/9) = 5
140
+ expect(layout.rows).toBe(25);
141
+ expect(layout.columns).toBe(5);
142
+ });
143
+ });
144
+
145
+ describe("narrow-width regression", () => {
146
+ it("keeps portrait aspect-ratio error bounded at narrow widths", () => {
147
+ const portrait: ImageDimensions = { widthPx: 1179, heightPx: 2556 };
148
+ for (const width of [8, 12, 20, 30]) {
149
+ const layout = calculateImageLayout(portrait, width, DEFAULT_CELL);
150
+ expect(layout.columns).toBeGreaterThanOrEqual(1);
151
+ expect(layout.rows).toBeGreaterThanOrEqual(1);
152
+ expect(getAspectError(portrait, layout.columns, layout.rows)).toBeLessThan(0.12);
153
+ }
154
+ });
155
+
156
+ it("clamps non-positive maxWidthCells inputs to one column", () => {
157
+ const portrait: ImageDimensions = { widthPx: 1179, heightPx: 2556 };
158
+ expect(calculateImageLayout(portrait, 0, DEFAULT_CELL).columns).toBe(1);
159
+ expect(calculateImageLayout(portrait, -7, DEFAULT_CELL).columns).toBe(1);
160
+ });
161
+ });
162
+
163
+ describe("maxHeight boundary transitions", () => {
164
+ it("switches to height-clamp behavior exactly past the row boundary", () => {
165
+ const portrait: ImageDimensions = { widthPx: 1000, heightPx: 2000 };
166
+ const atBoundary = calculateImageLayout(portrait, 25, DEFAULT_CELL, 25);
167
+ const aboveBoundary = calculateImageLayout(portrait, 26, DEFAULT_CELL, 25);
168
+
169
+ expect(atBoundary.rows).toBe(25);
170
+ expect(atBoundary.columns).toBe(25);
171
+ expect(aboveBoundary.rows).toBe(25);
172
+ expect(aboveBoundary.columns).toBe(25);
173
+ });
174
+ });
175
+ });
176
+
177
+ // ── detectImageFormat ────────────────────────────────────────────────────────
178
+
179
+ describe("detectImageFormat", () => {
180
+ it("detects PNG from magic bytes", () => {
181
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
182
+ expect(detectImageFormat(png)).toBe("png");
183
+ });
184
+
185
+ it("detects JPEG from magic bytes", () => {
186
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
187
+ expect(detectImageFormat(jpeg)).toBe("jpeg");
188
+ });
189
+
190
+ it("detects JPEG with EXIF marker", () => {
191
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe1, 0, 0, 0, 0, 0, 0, 0, 0]);
192
+ expect(detectImageFormat(jpeg)).toBe("jpeg");
193
+ });
194
+
195
+ it("detects GIF87a", () => {
196
+ // "GIF87a" = 47 49 46 38 37 61
197
+ const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0]);
198
+ expect(detectImageFormat(gif)).toBe("gif");
199
+ });
200
+
201
+ it("detects GIF89a", () => {
202
+ const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0]);
203
+ expect(detectImageFormat(gif)).toBe("gif");
204
+ });
205
+
206
+ it("detects WebP from RIFF...WEBP header", () => {
207
+ // "RIFF" + 4 bytes size + "WEBP"
208
+ const webp = Buffer.from([
209
+ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
210
+ ]);
211
+ expect(detectImageFormat(webp)).toBe("webp");
212
+ });
213
+
214
+ it("returns null for text content", () => {
215
+ const text = Buffer.from("Hello, world! This is plain text.");
216
+ expect(detectImageFormat(text)).toBeNull();
217
+ });
218
+
219
+ it("returns null for empty buffer", () => {
220
+ expect(detectImageFormat(Buffer.alloc(0))).toBeNull();
221
+ });
222
+
223
+ it("returns null for buffer too short for any format", () => {
224
+ expect(detectImageFormat(Buffer.from([0xff, 0xd8]))).toBeNull();
225
+ });
226
+
227
+ it("returns null for partial PNG header", () => {
228
+ // Only 4 bytes of PNG header (needs 8)
229
+ expect(detectImageFormat(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
230
+ });
231
+
232
+ it("returns null for RIFF without WEBP marker", () => {
233
+ // RIFF + size + "AVI " instead of "WEBP"
234
+ const avi = Buffer.from([
235
+ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x56, 0x49, 0x20,
236
+ ]);
237
+ expect(detectImageFormat(avi)).toBeNull();
238
+ });
239
+ });
240
+
241
+ // ── imageFormatToMime ────────────────────────────────────────────────────────
242
+
243
+ describe("imageFormatToMime", () => {
244
+ it("maps png to image/png", () => {
245
+ expect(imageFormatToMime("png")).toBe("image/png");
246
+ });
247
+
248
+ it("maps jpeg to image/jpeg", () => {
249
+ expect(imageFormatToMime("jpeg")).toBe("image/jpeg");
250
+ });
251
+
252
+ it("maps gif to image/gif", () => {
253
+ expect(imageFormatToMime("gif")).toBe("image/gif");
254
+ });
255
+
256
+ it("maps webp to image/webp", () => {
257
+ expect(imageFormatToMime("webp")).toBe("image/webp");
258
+ });
259
+ });
260
+
261
+ // ── createImageMetadata ──────────────────────────────────────────────────────
262
+
263
+ describe("createImageMetadata", () => {
264
+ it("creates metadata with resized=false when dimensions match", () => {
265
+ const dims = { widthPx: 1920, heightPx: 1080 };
266
+ const meta = createImageMetadata(dims, dims, "png", 245000);
267
+ expect(meta.resized).toBe(false);
268
+ expect(meta.originalWidth).toBe(1920);
269
+ expect(meta.displayWidth).toBe(1920);
270
+ expect(meta.format).toBe("png");
271
+ expect(meta.sizeBytes).toBe(245000);
272
+ });
273
+
274
+ it("creates metadata with resized=true when dimensions differ", () => {
275
+ const original = { widthPx: 3840, heightPx: 2160 };
276
+ const display = { widthPx: 1920, heightPx: 1080 };
277
+ const meta = createImageMetadata(original, display, "jpeg");
278
+ expect(meta.resized).toBe(true);
279
+ expect(meta.originalWidth).toBe(3840);
280
+ expect(meta.displayWidth).toBe(1920);
281
+ expect(meta.sizeBytes).toBeUndefined();
282
+ });
283
+
284
+ it("detects resize when only width differs", () => {
285
+ const original = { widthPx: 1920, heightPx: 1080 };
286
+ const display = { widthPx: 960, heightPx: 1080 };
287
+ expect(createImageMetadata(original, display, null).resized).toBe(true);
288
+ });
289
+
290
+ it("handles null format", () => {
291
+ const dims = { widthPx: 100, heightPx: 100 };
292
+ expect(createImageMetadata(dims, dims, null).format).toBeNull();
293
+ });
294
+ });
295
+
296
+ // ── formatImageDimensions ────────────────────────────────────────────────────
297
+
298
+ describe("formatImageDimensions", () => {
299
+ it("shows simple dimensions when not resized", () => {
300
+ const meta = createImageMetadata(
301
+ { widthPx: 1920, heightPx: 1080 },
302
+ { widthPx: 1920, heightPx: 1080 },
303
+ "png"
304
+ );
305
+ expect(formatImageDimensions(meta)).toBe("1920×1080");
306
+ });
307
+
308
+ it("shows original → display when resized", () => {
309
+ const meta = createImageMetadata(
310
+ { widthPx: 3840, heightPx: 2160 },
311
+ { widthPx: 800, heightPx: 450 },
312
+ "png"
313
+ );
314
+ expect(formatImageDimensions(meta)).toBe("3840×2160 → 800×450");
315
+ });
316
+ });
317
+
318
+ // ── renderImage ─────────────────────────────────────────────────────────────
319
+
320
+ describe("renderImage", () => {
321
+ it("emits Kitty sequences with valid columns and no explicit rows", () => {
322
+ withCapabilityEnv({ TERM_PROGRAM: "kitty" }, () => {
323
+ const result = renderImage("AA==", { widthPx: 1179, heightPx: 2556 }, { maxWidthCells: 8 });
324
+ expect(result).not.toBeNull();
325
+
326
+ const columnsMatch = result?.sequence.match(/c=(\d+)/);
327
+ expect(columnsMatch).not.toBeNull();
328
+ expect(Number(columnsMatch?.[1])).toBe(result?.columns);
329
+ expect(result?.sequence).not.toContain(",r=");
330
+ expect(result?.columns).toBeGreaterThanOrEqual(1);
331
+ expect(result?.rows).toBeGreaterThanOrEqual(1);
332
+ });
333
+ });
334
+
335
+ it("keeps render metadata aligned with calculated layout at narrow widths", () => {
336
+ withCapabilityEnv({ TERM_PROGRAM: "kitty" }, () => {
337
+ const portrait: ImageDimensions = { widthPx: 1179, heightPx: 2556 };
338
+ for (const width of [8, 12, 20, 30]) {
339
+ const result = renderImage("AA==", portrait, { maxWidthCells: width });
340
+ expect(result).not.toBeNull();
341
+
342
+ const layout = calculateImageLayout(portrait, width, DEFAULT_CELL);
343
+ expect(result?.columns).toBe(layout.columns);
344
+ expect(result?.rows).toBe(layout.rows);
345
+ }
346
+ });
347
+ });
348
+
349
+ it("emits iTerm2 sequence width/height params that match computed layout", () => {
350
+ withCapabilityEnv({ TERM_PROGRAM: "iterm.app" }, () => {
351
+ const result = renderImage("AA==", { widthPx: 1920, heightPx: 1080 }, { maxWidthCells: 30 });
352
+ expect(result).not.toBeNull();
353
+ expect(result?.sequence).toContain("\x1b]1337;File=");
354
+ expect(result?.sequence).toContain("height=auto");
355
+
356
+ const widthMatch = result?.sequence.match(/width=(\d+)/);
357
+ expect(widthMatch).not.toBeNull();
358
+ expect(Number(widthMatch?.[1])).toBe(result?.columns);
359
+ expect(result?.columns).toBeGreaterThanOrEqual(1);
360
+ expect(result?.rows).toBeGreaterThanOrEqual(1);
361
+ });
362
+ });
363
+ });