chenbhao commited on
Commit
f7140ab
·
1 Parent(s): 70a8e01
src/ink/log-update.ts CHANGED
@@ -302,10 +302,40 @@ export class LogUpdate {
302
  let currentStyleId = stylePool.none
303
  let currentHyperlink: Hyperlink = undefined
304
 
 
 
 
305
  // First pass: render changes to existing rows (rows < prev.screen.height)
306
  let needsFullReset = false
307
  let resetTriggerY = -1
308
  diffEach(prev.screen, next.screen, (x, y, removed, added) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  // Skip new rows - we'll render them directly after
310
  if (growing && y >= prev.screen.height) {
311
  return
@@ -340,14 +370,6 @@ export class LogUpdate {
340
  return
341
  }
342
 
343
- // If the cell outside the viewport range has changed, we need to reset
344
- // because we can't move the cursor there to draw.
345
- if (y < viewportY) {
346
- needsFullReset = true
347
- resetTriggerY = y
348
- return true // early exit
349
- }
350
-
351
  moveCursorTo(screen, x, y)
352
 
353
  if (added) {
@@ -408,23 +430,10 @@ export class LogUpdate {
408
  prev.screen.height,
409
  next.screen.height,
410
  stylePool,
 
411
  )
412
  }
413
 
414
- // Emit raw writes (APC/DCS) at viewport-adjusted positions so native
415
- // images appear at the correct scroll position every frame, even when
416
- // the diff loop skips unchanged rows. The CUP is rewritten to target
417
- // a viewport-relative row instead of an absolute screen-buffer row.
418
- for (const [row, rawWrite] of next.screen.rawWritesAtRow) {
419
- if (row < viewportY) continue
420
- const vpRow = row - viewportY
421
- const adjusted = rawWrite.replace(
422
- /^\x1b\[(\d+);(\d+)H/,
423
- (_, __, col) => `\x1b[${vpRow + 1};${col}H`,
424
- )
425
- screen.diff.push({ type: 'stdout', content: adjusted })
426
- }
427
-
428
  // Restore cursor. Skipped in alt-screen: the cursor is hidden, its
429
  // position only matters as the starting point for the NEXT frame's
430
  // relative moves, and in alt-screen the next frame always begins with
@@ -532,17 +541,13 @@ function renderFrame(
532
  stylePool: StylePool,
533
  ): void {
534
  renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
535
- // Emit raw writes (APC/DCS) after cells so native images are drawn at
536
- // the correct viewport position. Full reset always starts from (0,0),
537
- // so the original CUP row (screen-buffer row + 1) is already correct.
538
- for (const [, rawWrite] of frame.screen.rawWritesAtRow) {
539
- screen.diff.push({ type: 'stdout', content: rawWrite })
540
- }
541
  }
542
 
543
  /**
544
  * Render a slice of rows from the frame's screen.
545
  * Each row is rendered followed by a newline. Cursor ends at (0, endY).
 
 
546
  */
547
  function renderFrameSlice(
548
  screen: VirtualScreen,
@@ -550,6 +555,7 @@ function renderFrameSlice(
550
  startY: number,
551
  endY: number,
552
  stylePool: StylePool,
 
553
  ): VirtualScreen {
554
  let currentStyleId = stylePool.none
555
  let currentHyperlink: Hyperlink = undefined
@@ -579,6 +585,28 @@ function renderFrameSlice(
579
  })
580
  }
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  // Reset at start of each line — no cell rendered yet
583
  lastRenderedStyleId = -1
584
 
 
302
  let currentStyleId = stylePool.none
303
  let currentHyperlink: Hyperlink = undefined
304
 
305
+ // Track which rows had raw writes emitted to avoid duplicates
306
+ const emittedRawRows = new Set<number>()
307
+
308
  // First pass: render changes to existing rows (rows < prev.screen.height)
309
  let needsFullReset = false
310
  let resetTriggerY = -1
311
  diffEach(prev.screen, next.screen, (x, y, removed, added) => {
312
+ // If the cell outside the viewport range has changed, we need to reset
313
+ // because we can't move the cursor there to draw.
314
+ if (y < viewportY) {
315
+ needsFullReset = true
316
+ resetTriggerY = y
317
+ return true // early exit
318
+ }
319
+
320
+ // Emit raw write (APC/DCS) with viewport-adjusted CUP so native
321
+ // images appear at the correct scroll position within the viewport.
322
+ if (!emittedRawRows.has(y)) {
323
+ emittedRawRows.add(y)
324
+ const rawWrite = next.screen.rawWritesAtRow.get(y)
325
+ if (rawWrite) {
326
+ const vpRow = y - viewportY
327
+ const adjusted = rawWrite.replace(
328
+ /^\x1b\[(\d+);(\d+)H/,
329
+ (_, __, col) => `\x1b[${vpRow + 1};${col}H`,
330
+ )
331
+ screen.diff.push({ type: 'stdout', content: adjusted })
332
+ // Sync virtual cursor to screen-buffer row so the cell
333
+ // loop's moveCursorTo computes correct deltas.
334
+ screen.txn(prev => {
335
+ return [[], { dx: x - prev.x, dy: y - prev.y }]
336
+ })
337
+ }
338
+ }
339
  // Skip new rows - we'll render them directly after
340
  if (growing && y >= prev.screen.height) {
341
  return
 
370
  return
371
  }
372
 
 
 
 
 
 
 
 
 
373
  moveCursorTo(screen, x, y)
374
 
375
  if (added) {
 
430
  prev.screen.height,
431
  next.screen.height,
432
  stylePool,
433
+ viewportY,
434
  )
435
  }
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  // Restore cursor. Skipped in alt-screen: the cursor is hidden, its
438
  // position only matters as the starting point for the NEXT frame's
439
  // relative moves, and in alt-screen the next frame always begins with
 
541
  stylePool: StylePool,
542
  ): void {
543
  renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
 
 
 
 
 
 
544
  }
545
 
546
  /**
547
  * Render a slice of rows from the frame's screen.
548
  * Each row is rendered followed by a newline. Cursor ends at (0, endY).
549
+ * @param viewportY Number of rows above the viewport (scrollback), for
550
+ * viewport-adjusted raw write (APC/DCS) CUP positioning.
551
  */
552
  function renderFrameSlice(
553
  screen: VirtualScreen,
 
555
  startY: number,
556
  endY: number,
557
  stylePool: StylePool,
558
+ viewportY = 0,
559
  ): VirtualScreen {
560
  let currentStyleId = stylePool.none
561
  let currentHyperlink: Hyperlink = undefined
 
585
  })
586
  }
587
 
588
+ // Emit raw write (APC/DCS) with viewport-adjusted CUP so native
589
+ // images appear at the correct scroll position within the viewport.
590
+ const rawWrite = frame.screen.rawWritesAtRow.get(y)
591
+ if (rawWrite) {
592
+ const vpRow = y - viewportY
593
+ const adjusted = rawWrite.replace(
594
+ /^\x1b\[(\d+);(\d+)H/,
595
+ (_, __, col) => `\x1b[${vpRow + 1};${col}H`,
596
+ )
597
+ screen.diff.push({ type: 'stdout', content: adjusted })
598
+ // Sync virtual cursor to screen-buffer row so the cell
599
+ // loop's moveCursorTo computes correct deltas.
600
+ const cupMatch = rawWrite.match(/^\x1b\[(\d+);(\d+)H/)
601
+ if (cupMatch) {
602
+ const targetY = parseInt(cupMatch[1], 10) - 1
603
+ const targetX = parseInt(cupMatch[2], 10) - 1
604
+ screen.txn(prev => {
605
+ return [[], { dx: targetX - prev.x, dy: targetY - prev.y }]
606
+ })
607
+ }
608
+ }
609
+
610
  // Reset at start of each line — no cell rendered yet
611
  lastRenderedStyleId = -1
612
 
src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -8,6 +8,7 @@ import { buildTool, type ToolDef } from '../../Tool.js'
8
  import { logForDebugging } from '../../utils/debug.js'
9
  import {
10
  detectImageProtocol,
 
11
  renderImageWithTimgSync,
12
  } from '../../utils/terminalImage.js'
13
 
@@ -130,6 +131,7 @@ export const ImageShowTool = buildTool({
130
  message: string
131
  kittyOutput?: string
132
  timgOutput?: string
 
133
  },
134
  _progressMessages,
135
  _options,
@@ -138,26 +140,23 @@ export const ImageShowTool = buildTool({
138
  return null
139
  }
140
 
141
- // Block-mode placeholder rendered in-band via RawAnsi so Ink knows the
142
- // image dimensions and its virtual cursor stays in sync. When Kitty
143
- // protocol is available, visible block characters are replaced with
144
- // spaces so the native image shows through transparently, while the
145
- // line structure (row count, per-row visual width) is preserved for
146
- // Ink DOM cursor tracking.
 
 
 
 
 
 
 
147
  if (content.timgOutput) {
148
  const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
149
- let lines = cleaned.split('\n').filter(l => l.length > 0)
150
  if (lines.length > 0) {
151
- if (content.kittyOutput) {
152
- // Strip ANSI SGR codes and replace non-space visible chars with
153
- // spaces. The native Kitty image will show through these spaces.
154
- lines = lines.map(l =>
155
- l.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/[^\s]/g, ' '),
156
- )
157
- const width = Math.max(...lines.map(l => l.length))
158
- lines[0] = content.kittyOutput + lines[0]
159
- return <RawAnsi lines={lines} width={width} />
160
- }
161
  const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
162
  const width = Math.max(...lines.map(l => ansiStrip(l).length))
163
  return <RawAnsi lines={lines} width={width} />
@@ -254,24 +253,34 @@ export const ImageShowTool = buildTool({
254
 
255
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
256
 
257
- // Hybrid rendering:
258
- // 1. Kitty protocol via timg for native-quality overlay (when supported)
259
- // 2. Block-mode via timg for Ink DOM cursor tracking (always, as fallback)
260
  const protocol = detectImageProtocol()
261
  let kittyOutput: string | undefined
262
  let timgOutput: string | undefined
 
263
 
264
  if (protocol === 'kitty') {
265
  const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
266
  if (rawKitty) {
267
  kittyOutput = wrapForMultiplexer(rawKitty)
268
- logForDebugging('ImageShow: generated Kitty protocol output via timg')
 
 
 
 
 
 
 
269
  }
270
  }
271
 
272
- timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
273
- if (timgOutput) {
274
- logForDebugging('ImageShow: generated block-mode output via timg')
 
 
275
  }
276
 
277
  return {
@@ -286,6 +295,7 @@ export const ImageShowTool = buildTool({
286
  format,
287
  kittyOutput,
288
  timgOutput,
 
289
  },
290
  }
291
  },
 
8
  import { logForDebugging } from '../../utils/debug.js'
9
  import {
10
  detectImageProtocol,
11
+ getImageRowsCount,
12
  renderImageWithTimgSync,
13
  } from '../../utils/terminalImage.js'
14
 
 
131
  message: string
132
  kittyOutput?: string
133
  timgOutput?: string
134
+ imageRows?: number
135
  },
136
  _progressMessages,
137
  _options,
 
140
  return null
141
  }
142
 
143
+ // When Kitty protocol is available, reserve image-height rows of spaces
144
+ // in Ink's virtual DOM for layout, while the native image is rendered
145
+ // directly via the APC escape sequence (stored in rawWritesAtRow). This
146
+ // avoids the issue of block-mode overlay characters interfering with the
147
+ // native image display.
148
+ if (content.kittyOutput && content.imageRows && content.imageRows > 0) {
149
+ const width = process.stdout.columns ?? 80
150
+ const lines = Array.from({ length: content.imageRows }, () => ' '.repeat(width))
151
+ lines[0] = content.kittyOutput + lines[0]
152
+ return <RawAnsi lines={lines} width={width} />
153
+ }
154
+
155
+ // Block-mode fallback when Kitty protocol is not supported.
156
  if (content.timgOutput) {
157
  const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
158
+ const lines = cleaned.split('\n').filter(l => l.length > 0)
159
  if (lines.length > 0) {
 
 
 
 
 
 
 
 
 
 
160
  const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
161
  const width = Math.max(...lines.map(l => ansiStrip(l).length))
162
  return <RawAnsi lines={lines} width={width} />
 
253
 
254
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
255
 
256
+ // Rendering approach:
257
+ // 1. Kitty protocol via timg for native-quality display (when supported)
258
+ // 2. Block-mode via timg as fallback for terminals without Kitty support
259
  const protocol = detectImageProtocol()
260
  let kittyOutput: string | undefined
261
  let timgOutput: string | undefined
262
+ let imageRows = 0
263
 
264
  if (protocol === 'kitty') {
265
  const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
266
  if (rawKitty) {
267
  kittyOutput = wrapForMultiplexer(rawKitty)
268
+ imageRows = getImageRowsCount(buffer, format)
269
+ logForDebugging(
270
+ `ImageShow: generated Kitty protocol output, image rows = ${imageRows}`,
271
+ )
272
+ }
273
+ // If we couldn't determine image rows, fall back to block-mode layout
274
+ if (imageRows === 0) {
275
+ kittyOutput = undefined
276
  }
277
  }
278
 
279
+ if (!kittyOutput) {
280
+ timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
281
+ if (timgOutput) {
282
+ logForDebugging('ImageShow: generated block-mode output via timg')
283
+ }
284
  }
285
 
286
  return {
 
295
  format,
296
  kittyOutput,
297
  timgOutput,
298
+ imageRows,
299
  },
300
  }
301
  },
src/utils/terminalImage.ts CHANGED
@@ -232,6 +232,63 @@ export async function renderImageWithTimg(
232
  }
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  /**
236
  * Synchronous version of renderImageWithTimg. Blocks the event loop while
237
  * spawning timg, which prevents Ink from rendering between the tool call
 
232
  }
233
  }
234
 
235
+ /**
236
+ * Determine the number of terminal rows an image occupies when rendered.
237
+ * Runs `timg -p q` (block-mode) and counts the output lines.
238
+ * Used to reserve layout space in Ink's virtual DOM when the Kitty
239
+ * protocol is used for native-quality rendering.
240
+ *
241
+ * @param buffer - The raw image buffer
242
+ * @param format - Image format (png, jpeg, gif, webp)
243
+ * @param columns - Optional terminal width in character columns (default: stdout.columns)
244
+ * @returns Number of terminal rows, or 0 on failure
245
+ */
246
+ export function getImageRowsCount(
247
+ buffer: Buffer,
248
+ format: string,
249
+ columns?: number,
250
+ ): number {
251
+ try {
252
+ const timgPath = whichSync('timg')
253
+ if (!timgPath) return 0
254
+
255
+ const cols = columns ?? process.stdout.columns ?? 80
256
+ const termRows = process.stdout.rows ?? 40
257
+ const maxRows = Math.max(10, Math.floor(termRows * 0.5))
258
+ const tmpDir = mkdtempSync(join(tmpdir(), 'versperclaw-timg-rows-'))
259
+ const ext = format === 'jpeg' ? 'jpg' : format
260
+ const tmpFile = join(tmpDir, `image.${ext}`)
261
+
262
+ try {
263
+ writeFileSync(tmpFile, buffer)
264
+
265
+ const stdout = execFileSync(
266
+ timgPath,
267
+ ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
268
+ { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
269
+ )
270
+
271
+ // Count non-empty lines; strip ANSI escape sequences first so we
272
+ // only count lines that actually contain block-mode content.
273
+ const lines = stdout
274
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
275
+ .replace(/\x1b\[?25[hl]/g, '')
276
+ .split('\n')
277
+ .filter(l => l.trim().length > 0)
278
+
279
+ return lines.length
280
+ } finally {
281
+ try {
282
+ rmSync(tmpDir, { recursive: true, force: true })
283
+ } catch {
284
+ // ignore cleanup errors
285
+ }
286
+ }
287
+ } catch {
288
+ return 0
289
+ }
290
+ }
291
+
292
  /**
293
  * Synchronous version of renderImageWithTimg. Blocks the event loop while
294
  * spawning timg, which prevents Ink from rendering between the tool call