@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
@@ -34,9 +34,7 @@ export function getCellDimensions(): CellDimensions {
34
34
  }
35
35
 
36
36
  export function setCellDimensions(dims: CellDimensions): void {
37
- if (dims.widthPx > 0 && dims.heightPx > 0) {
38
- cellDimensions = dims;
39
- }
37
+ cellDimensions = dims;
40
38
  }
41
39
 
42
40
  export function detectCapabilities(): TerminalCapabilities {
@@ -44,6 +42,16 @@ export function detectCapabilities(): TerminalCapabilities {
44
42
  const term = process.env.TERM?.toLowerCase() || "";
45
43
  const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
46
44
 
45
+ // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
46
+ // sequences differently). Force hyperlinks off whenever we detect them, even
47
+ // when the outer terminal would otherwise support OSC 8. Image protocols are
48
+ // also unreliable under tmux/screen, so leave `images: null` for safety.
49
+ const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
50
+ if (inTmuxOrScreen) {
51
+ const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
52
+ return { images: null, trueColor, hyperlinks: false };
53
+ }
54
+
47
55
  if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
48
56
  return { images: "kitty", trueColor: true, hyperlinks: true };
49
57
  }
@@ -68,8 +76,12 @@ export function detectCapabilities(): TerminalCapabilities {
68
76
  return { images: null, trueColor: true, hyperlinks: true };
69
77
  }
70
78
 
79
+ // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
80
+ // text" on terminals that swallow it, which means the URL disappears from
81
+ // the rendered output. Default to the legacy `text (url)` behavior unless we
82
+ // have positively identified a hyperlink-capable terminal above.
71
83
  const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
72
- return { images: null, trueColor, hyperlinks: true };
84
+ return { images: null, trueColor, hyperlinks: false };
73
85
  }
74
86
 
75
87
  export function getCapabilities(): TerminalCapabilities {
@@ -83,6 +95,11 @@ export function resetCapabilitiesCache(): void {
83
95
  cachedCapabilities = null;
84
96
  }
85
97
 
98
+ /** Override the cached capabilities. Useful in tests to exercise both code paths. */
99
+ export function setCapabilities(caps: TerminalCapabilities): void {
100
+ cachedCapabilities = caps;
101
+ }
102
+
86
103
  const KITTY_PREFIX = "\x1b_G";
87
104
  const ITERM2_PREFIX = "\x1b]1337;File=";
88
105
 
@@ -189,79 +206,16 @@ export function encodeITerm2(
189
206
  return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
190
207
  }
191
208
 
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(
209
+ export function calculateImageRows(
232
210
  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) };
211
+ targetWidthCells: number,
212
+ cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }
213
+ ): number {
214
+ const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
215
+ const scale = targetWidthPx / imageDimensions.widthPx;
216
+ const scaledHeightPx = imageDimensions.heightPx * scale;
217
+ const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
218
+ return Math.max(1, rows);
265
219
  }
266
220
 
267
221
  export function getPngDimensions(base64Data: string): ImageDimensions | null {
@@ -389,156 +343,6 @@ export function getWebpDimensions(base64Data: string): ImageDimensions | null {
389
343
  }
390
344
  }
391
345
 
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
346
  export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
543
347
  if (mimeType === "image/png") {
544
348
  return getPngDimensions(base64Data);
@@ -555,21 +359,11 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD
555
359
  return null;
556
360
  }
557
361
 
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
362
  export function renderImage(
569
363
  base64Data: string,
570
364
  imageDimensions: ImageDimensions,
571
365
  options: ImageRenderOptions = {}
572
- ): { sequence: string; rows: number; columns: number; imageId?: number } | null {
366
+ ): { sequence: string; rows: number; imageId?: number } | null {
573
367
  const caps = getCapabilities();
574
368
 
575
369
  if (!caps.images) {
@@ -577,35 +371,40 @@ export function renderImage(
577
371
  }
578
372
 
579
373
  const maxWidth = options.maxWidthCells ?? 80;
580
- const layout = calculateImageLayout(
581
- imageDimensions,
582
- maxWidth,
583
- getCellDimensions(),
584
- options.maxHeightCells
585
- );
374
+ const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
586
375
 
587
376
  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 };
377
+ // Only use imageId if explicitly provided - static images don't need IDs
378
+ const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId: options.imageId });
379
+ return { sequence, rows, imageId: options.imageId };
595
380
  }
596
381
 
597
382
  if (caps.images === "iterm2") {
598
383
  const sequence = encodeITerm2(base64Data, {
599
- width: layout.columns,
384
+ width: maxWidth,
600
385
  height: "auto",
601
386
  preserveAspectRatio: options.preserveAspectRatio ?? true,
602
387
  });
603
- return { sequence, rows: layout.rows, columns: layout.columns };
388
+ return { sequence, rows };
604
389
  }
605
390
 
606
391
  return null;
607
392
  }
608
393
 
394
+ /**
395
+ * Wrap text in an OSC 8 hyperlink sequence.
396
+ * The text is rendered as a clickable hyperlink in terminals that support OSC 8
397
+ * (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
398
+ * In terminals that do not support OSC 8, the escape sequences are ignored
399
+ * and only the plain text is displayed.
400
+ *
401
+ * @param text - The visible text to display
402
+ * @param url - The URL to link to
403
+ */
404
+ export function hyperlink(text: string, url: string): string {
405
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
406
+ }
407
+
609
408
  export function imageFallback(
610
409
  mimeType: string,
611
410
  dimensions?: ImageDimensions,
@@ -1,7 +1,11 @@
1
1
  import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
2
4
  import { setKittyProtocolActive } from "./keys.js";
3
5
  import { StdinBuffer } from "./stdin-buffer.js";
4
6
 
7
+ const cjsRequire = createRequire(import.meta.url);
8
+
5
9
  /**
6
10
  * Minimal terminal interface for TUI
7
11
  */
@@ -30,9 +34,6 @@ export interface Terminal {
30
34
  // Whether Kitty keyboard protocol is active
31
35
  get kittyProtocolActive(): boolean;
32
36
 
33
- // Whether running inside tmux (using modifyOtherKeys instead of Kitty protocol)
34
- get isTmux(): boolean;
35
-
36
37
  // Cursor positioning (relative to current position)
37
38
  moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
38
39
 
@@ -45,21 +46,8 @@ export interface Terminal {
45
46
  clearFromCursor(): void; // Clear from cursor to end of screen
46
47
  clearScreen(): void; // Clear entire screen and move cursor to (0,0)
47
48
 
48
- // Mouse reporting
49
- enableMouse(): void; // Enable SGR mouse tracking (scroll, click)
50
- disableMouse(): void; // Disable mouse tracking
51
-
52
- // Screen buffer mode
53
- enterAlternateScreen(): void; // Switch to alternate screen buffer (no scrollback)
54
- leaveAlternateScreen(): void; // Restore normal screen buffer and scrollback
55
-
56
49
  // Title operations
57
50
  setTitle(title: string): void; // Set terminal window title
58
-
59
- /** Set terminal progress bar via OSC 9;4. Supported by Windows Terminal, iTerm2, ConEmu. */
60
- setProgress(percent: number): void;
61
- /** Clear terminal progress bar via OSC 9;4. */
62
- clearProgress(): void;
63
51
  }
64
52
 
65
53
  /**
@@ -70,24 +58,31 @@ export class ProcessTerminal implements Terminal {
70
58
  private inputHandler?: (data: string) => void;
71
59
  private resizeHandler?: () => void;
72
60
  private _kittyProtocolActive = false;
61
+ private _modifyOtherKeysActive = false;
73
62
  private stdinBuffer?: StdinBuffer;
74
63
  private stdinDataHandler?: (data: string) => void;
75
- private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
76
- private alternateScreenActive = false;
77
- private mouseActive = false;
64
+ private writeLogPath = (() => {
65
+ const env = process.env.PI_TUI_WRITE_LOG || "";
66
+ if (!env) return "";
67
+ try {
68
+ if (fs.statSync(env).isDirectory()) {
69
+ const now = new Date();
70
+ const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
71
+ return path.join(env, `tui-${ts}-${process.pid}.log`);
72
+ }
73
+ } catch {
74
+ // Not an existing directory - use as-is (file path)
75
+ }
76
+ return env;
77
+ })();
78
78
 
79
79
  get kittyProtocolActive(): boolean {
80
80
  return this._kittyProtocolActive;
81
81
  }
82
82
 
83
- get isTmux(): boolean {
84
- return !!process.env.TMUX;
85
- }
86
-
87
83
  start(onInput: (data: string) => void, onResize: () => void): void {
88
84
  this.inputHandler = onInput;
89
85
  this.resizeHandler = onResize;
90
- this.alternateScreenActive = false;
91
86
 
92
87
  // Save previous state and enable raw mode
93
88
  this.wasRaw = process.stdin.isRaw || false;
@@ -109,24 +104,16 @@ export class ProcessTerminal implements Terminal {
109
104
  process.kill(process.pid, "SIGWINCH");
110
105
  }
111
106
 
112
- // Mouse tracking (enableMouse/disableMouse) is available but NOT
113
- // enabled anywhere yet. The TUI has no scroll viewport — content
114
- // off-screen is only reachable via terminal scrollback (or tmux
115
- // copy-mode). Enabling mouse tracking steals scroll events without
116
- // providing scroll handling, making things strictly worse. Enable
117
- // only after implementing a scroll viewport in the TUI.
118
-
119
- // Enable keyboard protocol for modified key detection.
120
- // tmux doesn't support the Kitty keyboard protocol but does support xterm's
121
- // modifyOtherKeys. Detect tmux and use the appropriate protocol.
122
- if (process.env.TMUX) {
123
- this.setupTmuxInput();
124
- } else {
125
- // Query and enable Kitty keyboard protocol
126
- // The query handler intercepts input temporarily, then installs the user's handler
127
- // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
128
- this.queryAndEnableKittyProtocol();
129
- }
107
+ // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
108
+ // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
109
+ // events that lose modifier information. Must run AFTER setRawMode(true)
110
+ // since that resets console mode flags.
111
+ this.enableWindowsVTInput();
112
+
113
+ // Query and enable Kitty keyboard protocol
114
+ // The query handler intercepts input temporarily, then installs the user's handler
115
+ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
116
+ this.queryAndEnableKittyProtocol();
130
117
  }
131
118
 
132
119
  /**
@@ -176,32 +163,21 @@ export class ProcessTerminal implements Terminal {
176
163
 
177
164
  // Handler that pipes stdin data through the buffer
178
165
  this.stdinDataHandler = (data: string) => {
179
- this.stdinBuffer?.process(data);
166
+ this.stdinBuffer!.process(data);
180
167
  };
181
168
  }
182
169
 
183
- /**
184
- * Set up stdin handling for tmux without Kitty keyboard protocol.
185
- *
186
- * tmux doesn't support the Kitty keyboard protocol. Escape and Ctrl+C
187
- * arrive as raw bytes (\x1b and \x03) which the key matching handles natively.
188
- *
189
- * For Shift+Enter, tmux needs `extended-keys on` and `extended-keys-format csi-u`
190
- * in tmux.conf. With that config, we request modifyOtherKeys mode 1 so tmux
191
- * encodes modified keys (Shift+Enter → CSI 13;2 u) while leaving standard
192
- * keys (Escape, Ctrl+C, regular typing) as raw bytes.
193
- */
194
- private setupTmuxInput(): void {
195
- this.setupStdinBuffer();
196
- process.stdin.on("data", this.stdinDataHandler!);
197
- }
198
-
199
170
  /**
200
171
  * Query terminal for Kitty keyboard protocol support and enable if available.
201
172
  *
202
173
  * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
203
174
  * it supports the protocol and we enable it with CSI > 1 u.
204
175
  *
176
+ * If no Kitty response arrives shortly after startup, fall back to enabling
177
+ * xterm modifyOtherKeys mode 2. This is needed for tmux, which can forward
178
+ * modified enter keys as CSI-u when extended-keys is enabled, but may not
179
+ * answer the Kitty protocol query.
180
+ *
205
181
  * The response is detected in setupStdinBuffer's data handler, which properly
206
182
  * handles the case where the response arrives split across multiple stdin events.
207
183
  */
@@ -209,6 +185,41 @@ export class ProcessTerminal implements Terminal {
209
185
  this.setupStdinBuffer();
210
186
  process.stdin.on("data", this.stdinDataHandler!);
211
187
  process.stdout.write("\x1b[?u");
188
+ setTimeout(() => {
189
+ if (!this._kittyProtocolActive && !this._modifyOtherKeysActive) {
190
+ process.stdout.write("\x1b[>4;2m");
191
+ this._modifyOtherKeysActive = true;
192
+ }
193
+ }, 150);
194
+ }
195
+
196
+ /**
197
+ * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin
198
+ * console handle so the terminal sends VT sequences for modified keys
199
+ * (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW
200
+ * discards modifier state and Shift+Tab arrives as plain \t.
201
+ */
202
+ private enableWindowsVTInput(): void {
203
+ if (process.platform !== "win32") return;
204
+ try {
205
+ // Dynamic require to avoid bundling koffi's 74MB of cross-platform
206
+ // native binaries into every compiled binary. Koffi is only needed
207
+ // on Windows for VT input support.
208
+ const koffi = cjsRequire("koffi");
209
+ const k32 = koffi.load("kernel32.dll");
210
+ const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
211
+ const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
212
+ const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
213
+
214
+ const STD_INPUT_HANDLE = -10;
215
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
216
+ const handle = GetStdHandle(STD_INPUT_HANDLE);
217
+ const mode = new Uint32Array(1);
218
+ GetConsoleMode(handle, mode);
219
+ SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT);
220
+ } catch {
221
+ // koffi not available — Shift+Tab won't be distinguishable from Tab
222
+ }
212
223
  }
213
224
 
214
225
  async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
@@ -219,6 +230,10 @@ export class ProcessTerminal implements Terminal {
219
230
  this._kittyProtocolActive = false;
220
231
  setKittyProtocolActive(false);
221
232
  }
233
+ if (this._modifyOtherKeysActive) {
234
+ process.stdout.write("\x1b[>4;0m");
235
+ this._modifyOtherKeysActive = false;
236
+ }
222
237
 
223
238
  const previousHandler = this.inputHandler;
224
239
  this.inputHandler = undefined;
@@ -246,14 +261,6 @@ export class ProcessTerminal implements Terminal {
246
261
  }
247
262
 
248
263
  stop(): void {
249
- // Always restore normal screen buffer before exiting.
250
- if (this.alternateScreenActive) {
251
- this.leaveAlternateScreen();
252
- }
253
-
254
- // Disable mouse tracking
255
- this.disableMouse();
256
-
257
264
  // Disable bracketed paste mode
258
265
  process.stdout.write("\x1b[?2004l");
259
266
 
@@ -263,6 +270,10 @@ export class ProcessTerminal implements Terminal {
263
270
  this._kittyProtocolActive = false;
264
271
  setKittyProtocolActive(false);
265
272
  }
273
+ if (this._modifyOtherKeysActive) {
274
+ process.stdout.write("\x1b[>4;0m");
275
+ this._modifyOtherKeysActive = false;
276
+ }
266
277
 
267
278
  // Clean up StdinBuffer
268
279
  if (this.stdinBuffer) {
@@ -342,74 +353,8 @@ export class ProcessTerminal implements Terminal {
342
353
  process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
343
354
  }
344
355
 
345
- /**
346
- * Enable SGR mouse tracking.
347
- * Mode 1000 = button press/release (includes scroll wheel).
348
- * Mode 1006 = SGR extended format (avoids 223-column limit).
349
- */
350
- enableMouse(): void {
351
- if (this.mouseActive) return;
352
- process.stdout.write("\x1b[?1000h\x1b[?1006h");
353
- this.mouseActive = true;
354
- }
355
-
356
- /**
357
- * Disable mouse tracking and restore default terminal mouse handling.
358
- */
359
- disableMouse(): void {
360
- if (!this.mouseActive) return;
361
- process.stdout.write("\x1b[?1006l\x1b[?1000l");
362
- this.mouseActive = false;
363
- }
364
-
365
- /**
366
- * Switch to alternate screen buffer and clear it.
367
- * @returns void
368
- */
369
- enterAlternateScreen(): void {
370
- if (this.alternateScreenActive) return;
371
- process.stdout.write("\x1b[?1049h");
372
- this.alternateScreenActive = true;
373
- }
374
-
375
- /**
376
- * Restore normal screen buffer and previous scrollback.
377
- * @returns void
378
- */
379
- leaveAlternateScreen(): void {
380
- if (!this.alternateScreenActive) return;
381
- process.stdout.write("\x1b[?1049l");
382
- this.alternateScreenActive = false;
383
- }
384
-
385
356
  setTitle(title: string): void {
386
357
  // OSC 0;title BEL - set terminal window title
387
358
  process.stdout.write(`\x1b]0;${title}\x07`);
388
359
  }
389
-
390
- private lastProgressWrite = 0;
391
-
392
- /**
393
- * Set terminal progress bar via OSC 9;4.
394
- * Throttled to max 1 update per 100ms (percent=100 always passes through).
395
- * Supported by Windows Terminal (tab indicator), iTerm2 (title bar), ConEmu (taskbar).
396
- * Unsupported terminals gracefully ignore these sequences.
397
- * @param percent - Progress percentage, clamped to 0-100
398
- */
399
- setProgress(percent: number): void {
400
- const clamped = Math.max(0, Math.min(100, Math.round(percent)));
401
- const now = Date.now();
402
- if (clamped !== 100 && now - this.lastProgressWrite < 100) return;
403
- this.lastProgressWrite = now;
404
- process.stdout.write(`\x1b]9;4;1;${clamped}\x07`);
405
- }
406
-
407
- /**
408
- * Clear terminal progress bar via OSC 9;4.
409
- * Resets the throttle timestamp so a subsequent setProgress fires immediately.
410
- */
411
- clearProgress(): void {
412
- this.lastProgressWrite = 0;
413
- process.stdout.write("\x1b]9;4;0;0\x07");
414
- }
415
360
  }