@dungle-scrubs/tallow 0.9.4 → 0.9.6

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 (195) hide show
  1. package/dist/cli.js +7 -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 +24 -12
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +229 -146
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/interactive-reset.d.ts +49 -0
  10. package/dist/interactive-reset.d.ts.map +1 -0
  11. package/dist/interactive-reset.js +40 -0
  12. package/dist/interactive-reset.js.map +1 -0
  13. package/dist/pi-tui-editor-patch.d.ts +10 -0
  14. package/dist/pi-tui-editor-patch.d.ts.map +1 -0
  15. package/dist/pi-tui-editor-patch.js +159 -0
  16. package/dist/pi-tui-editor-patch.js.map +1 -0
  17. package/dist/pi-tui-patch.d.ts +2 -0
  18. package/dist/pi-tui-patch.d.ts.map +1 -0
  19. package/dist/pi-tui-patch.js +563 -0
  20. package/dist/pi-tui-patch.js.map +1 -0
  21. package/dist/pi-tui-settings-list-patch.d.ts +11 -0
  22. package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
  23. package/dist/pi-tui-settings-list-patch.js +38 -0
  24. package/dist/pi-tui-settings-list-patch.js.map +1 -0
  25. package/dist/reset-diagnostics.d.ts +69 -0
  26. package/dist/reset-diagnostics.d.ts.map +1 -0
  27. package/dist/reset-diagnostics.js +41 -0
  28. package/dist/reset-diagnostics.js.map +1 -0
  29. package/dist/sdk.d.ts +5 -21
  30. package/dist/sdk.d.ts.map +1 -1
  31. package/dist/sdk.js +180 -149
  32. package/dist/sdk.js.map +1 -1
  33. package/dist/workspace-transition-interactive.d.ts +1 -0
  34. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  35. package/dist/workspace-transition-interactive.js +7 -17
  36. package/dist/workspace-transition-interactive.js.map +1 -1
  37. package/extensions/__integration__/audit-findings.test.ts +4 -5
  38. package/extensions/_icons/index.ts +2 -4
  39. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  40. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  41. package/extensions/_shared/image-metadata.ts +99 -0
  42. package/extensions/_shared/inline-preview.ts +1 -1
  43. package/extensions/_shared/terminal-links.ts +22 -0
  44. package/extensions/ask-user-question-tool/index.ts +0 -3
  45. package/extensions/clear/__tests__/clear.test.ts +269 -2
  46. package/extensions/command-expansion/index.ts +1 -1
  47. package/extensions/context-files/index.ts +5 -1
  48. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  49. package/extensions/context-fork/extension.json +1 -1
  50. package/extensions/context-fork/index.ts +32 -0
  51. package/extensions/edit-tool-enhanced/index.ts +2 -1
  52. package/extensions/hooks/index.ts +33 -11
  53. package/extensions/loop/index.ts +14 -1
  54. package/extensions/lsp/index.ts +64 -13
  55. package/extensions/lsp/package.json +2 -2
  56. package/extensions/random-spinner/index.ts +7 -642
  57. package/extensions/read-tool-enhanced/index.ts +6 -8
  58. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  59. package/extensions/render-stabilizer/index.ts +6 -6
  60. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  61. package/extensions/slash-command-bridge/index.ts +14 -2
  62. package/extensions/subagent-tool/model-resolver.ts +274 -7
  63. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  64. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  65. package/extensions/web-search-tool/index.ts +2 -1
  66. package/extensions/write-tool-enhanced/index.ts +2 -1
  67. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  68. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  69. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  70. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  71. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  72. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  73. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  74. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  75. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  76. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  77. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  78. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  79. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  80. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  81. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  83. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  84. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  85. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  86. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  88. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  91. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  95. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  97. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  99. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  101. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  103. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  105. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  107. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  108. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  109. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  111. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  112. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  113. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  114. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  115. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  116. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  117. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  118. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  119. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  120. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  121. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  122. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  123. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  124. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  125. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  126. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  127. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  128. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  129. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  130. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  131. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  132. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  133. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  134. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  135. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  136. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  137. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  138. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  139. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  140. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  141. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  142. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  143. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  144. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  145. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  146. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  147. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  148. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  149. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  150. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  151. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  152. package/package.json +13 -13
  153. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  154. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  155. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  156. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  157. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  158. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  159. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  160. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  161. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  162. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  163. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  164. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  165. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  166. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  167. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  168. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  169. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  170. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  171. package/skills/tallow-expert/SKILL.md +1 -3
  172. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  173. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  174. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  175. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  176. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  177. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  178. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  179. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  180. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  181. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  182. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  183. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  184. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  185. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  186. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  187. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  188. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  189. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  190. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  191. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  192. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  193. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  194. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  195. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -1,363 +1,122 @@
1
1
  /**
2
- * Tests for image layout calculations: maxHeightCells clamping,
3
- * natural width clamping, and aspect ratio preservation.
2
+ * Tests for upstream terminal-image primitives kept in the fork.
4
3
  */
5
4
  import { describe, expect, it } from "bun:test";
6
5
  import {
7
- calculateImageLayout,
8
- createImageMetadata,
9
- detectImageFormat,
10
- formatImageDimensions,
6
+ calculateImageRows,
7
+ getGifDimensions,
8
+ getImageDimensions,
9
+ getPngDimensions,
10
+ getWebpDimensions,
11
11
  type ImageDimensions,
12
- imageFormatToMime,
13
12
  renderImage,
13
+ resetCapabilitiesCache,
14
14
  } from "../terminal-image.js";
15
- import { withCapabilityEnv } from "../test-utils/capability-env.js";
16
15
 
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);
16
+ const DEFAULT_CELL = { heightPx: 18, widthPx: 9 };
17
+
18
+ const TINY_PNG_BASE64 =
19
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W2fkAAAAASUVORK5CYII=";
20
+ const GIF_BASE64 = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=";
21
+ const WEBP_BASE64 =
22
+ "UklGRiYAAABXRUJQVlA4IBoAAAAQAgCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9LgAAA=";
23
+
24
+ type CapabilityEnvOverrides = Readonly<Record<string, string | undefined>>;
25
+
26
+ function withCapabilityEnv<T>(overrides: CapabilityEnvOverrides, run: () => T): T {
27
+ const keys = [
28
+ "COLORTERM",
29
+ "GHOSTTY_RESOURCES_DIR",
30
+ "ITERM_SESSION_ID",
31
+ "KITTY_WINDOW_ID",
32
+ "TERM",
33
+ "TERM_PROGRAM",
34
+ "TMUX",
35
+ "WEZTERM_PANE",
36
+ ] as const;
37
+ const previous: Partial<Record<(typeof keys)[number], string | undefined>> = {};
38
+ for (const key of keys) {
39
+ previous[key] = process.env[key];
40
+ if (Object.hasOwn(overrides, key)) {
41
+ const value = overrides[key];
42
+ if (value === undefined) {
43
+ delete process.env[key];
44
+ } else {
45
+ process.env[key] = value;
153
46
  }
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
- });
47
+ } else {
48
+ delete process.env[key];
49
+ }
50
+ }
51
+ resetCapabilitiesCache();
52
+ try {
53
+ return run();
54
+ } finally {
55
+ for (const key of keys) {
56
+ const value = previous[key];
57
+ if (value === undefined) {
58
+ delete process.env[key];
59
+ } else {
60
+ process.env[key] = value;
61
+ }
62
+ }
63
+ resetCapabilitiesCache();
64
+ }
65
+ }
247
66
 
248
- it("maps jpeg to image/jpeg", () => {
249
- expect(imageFormatToMime("jpeg")).toBe("image/jpeg");
250
- });
67
+ function kittyResult(maxWidthCells: number, imageDimensions: ImageDimensions) {
68
+ return withCapabilityEnv({ TERM_PROGRAM: "kitty", TMUX: undefined }, () =>
69
+ renderImage("AA==", imageDimensions, { maxWidthCells })
70
+ );
71
+ }
251
72
 
252
- it("maps gif to image/gif", () => {
253
- expect(imageFormatToMime("gif")).toBe("image/gif");
73
+ describe("terminal-image", () => {
74
+ it("calculates image rows from target width and cell size", () => {
75
+ const rows = calculateImageRows({ heightPx: 900, widthPx: 1800 }, 60, DEFAULT_CELL);
76
+ expect(rows).toBe(15);
254
77
  });
255
78
 
256
- it("maps webp to image/webp", () => {
257
- expect(imageFormatToMime("webp")).toBe("image/webp");
79
+ it("parses PNG dimensions", () => {
80
+ expect(getPngDimensions(TINY_PNG_BASE64)).toEqual({ heightPx: 1, widthPx: 1 });
258
81
  });
259
- });
260
-
261
- // ── createImageMetadata ──────────────────────────────────────────────────────
262
82
 
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);
83
+ it("parses GIF dimensions", () => {
84
+ expect(getGifDimensions(GIF_BASE64)).toEqual({ heightPx: 1, widthPx: 1 });
272
85
  });
273
86
 
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();
87
+ it("parses WEBP dimensions", () => {
88
+ const dims = getWebpDimensions(WEBP_BASE64);
89
+ expect(dims).not.toBeNull();
90
+ expect(dims?.widthPx).toBeGreaterThanOrEqual(1);
91
+ expect(dims?.heightPx).toBeGreaterThanOrEqual(1);
282
92
  });
283
93
 
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);
94
+ it("dispatches getImageDimensions by mime type", () => {
95
+ expect(getImageDimensions(TINY_PNG_BASE64, "image/png")).toEqual({ heightPx: 1, widthPx: 1 });
96
+ expect(getImageDimensions(GIF_BASE64, "image/gif")).toEqual({ heightPx: 1, widthPx: 1 });
288
97
  });
289
98
 
290
- it("handles null format", () => {
291
- const dims = { widthPx: 100, heightPx: 100 };
292
- expect(createImageMetadata(dims, dims, null).format).toBeNull();
99
+ it("renders kitty images with rows derived from the image size", () => {
100
+ const result = kittyResult(8, { heightPx: 2556, widthPx: 1179 });
101
+ expect(result).not.toBeNull();
102
+ expect(result?.sequence).toContain("\x1b_G");
103
+ expect(result?.rows).toBe(calculateImageRows({ heightPx: 2556, widthPx: 1179 }, 8));
293
104
  });
294
- });
295
-
296
- // ── formatImageDimensions ────────────────────────────────────────────────────
297
105
 
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"
106
+ it("renders iTerm images with auto height", () => {
107
+ const result = withCapabilityEnv({ TERM_PROGRAM: "iTerm.app", TMUX: undefined }, () =>
108
+ renderImage("AA==", { heightPx: 1080, widthPx: 1920 }, { maxWidthCells: 30 })
304
109
  );
305
- expect(formatImageDimensions(meta)).toBe("1920×1080");
110
+ expect(result).not.toBeNull();
111
+ expect(result?.sequence).toContain("\x1b]1337;File=");
112
+ expect(result?.sequence).toContain("height=auto");
306
113
  });
307
114
 
308
- it("shows original display when resized", () => {
309
- const meta = createImageMetadata(
310
- { widthPx: 3840, heightPx: 2160 },
311
- { widthPx: 800, heightPx: 450 },
312
- "png"
115
+ it("returns null when image protocols are unavailable", () => {
116
+ const result = withCapabilityEnv(
117
+ { TERM: "tmux-256color", TERM_PROGRAM: "unknown", TMUX: "1" },
118
+ () => renderImage("AA==", { heightPx: 1080, widthPx: 1920 }, { maxWidthCells: 30 })
313
119
  );
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
- });
120
+ expect(result).toBeNull();
362
121
  });
363
122
  });
@@ -250,7 +250,7 @@ describe("TUI render scheduling", () => {
250
250
  for (let index = 0; index < 25; index += 1) {
251
251
  tui.requestRender();
252
252
  }
253
- await flushIO();
253
+ await waitFor(() => component.renderCount === 1 && terminal.writes.length === 1);
254
254
 
255
255
  expect(component.renderCount).toBe(1);
256
256
  expect(terminal.writes).toHaveLength(1);