chenbhao Claude Big Pickle commited on
Commit
c4d362b
·
1 Parent(s): f7140ab

refactor: emit native image sequences after cell content with cursor hide/show

Browse files

Move raw APC/DCS emission from before cell rendering to after, wrapping
with cursor-hide/show sequences to prevent cursor flicker. Remove CUP
positioning from rawWritesAtRow entries since the cursor is already at
the correct position via CR+LF or moveCursorTo. Refactor ImageShowTool
to use writeRaw via TerminalWriteContext context instead of embedding
raw sequences in Ink's virtual DOM RawAnsi.

Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>

src/ink/log-update.ts CHANGED
@@ -317,25 +317,6 @@ export class LogUpdate {
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
@@ -400,6 +381,24 @@ export class LogUpdate {
400
  return [patches, { dx: 1, dy: 0 }]
401
  })
402
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  })
404
  if (needsFullReset) {
405
  return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
@@ -585,28 +584,6 @@ function renderFrameSlice(
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
 
@@ -658,6 +635,19 @@ function renderFrameSlice(
658
  currentHyperlink,
659
  undefined,
660
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  // CR+LF at end of row — \r resets to column 0, \n moves to next line.
662
  // Without \r, the terminal cursor stays at whatever column content ended
663
  // (since we skip trailing spaces, this can be mid-row).
 
317
  return true // early exit
318
  }
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  // Skip new rows - we'll render them directly after
321
  if (growing && y >= prev.screen.height) {
322
  return
 
381
  return [patches, { dx: 1, dy: 0 }]
382
  })
383
  }
384
+
385
+ // Emit cursor-hide + bare APC/DCS + cursor-show after cell content
386
+ // so the native image overlays cell characters with no cursor flicker.
387
+ // No CUP prefix — the cursor is already at the correct row/column
388
+ // from moveCursorTo above.
389
+ if (!emittedRawRows.has(y)) {
390
+ emittedRawRows.add(y)
391
+ const rw = next.screen.rawWritesAtRow.get(y)
392
+ if (rw) {
393
+ screen.diff.push({
394
+ type: 'stdout',
395
+ content: '\x1b[?25l' + rw + '\x1b[?25h',
396
+ })
397
+ screen.txn(prev => {
398
+ return [[], { dx: x - prev.x, dy: y - prev.y }]
399
+ })
400
+ }
401
+ }
402
  })
403
  if (needsFullReset) {
404
  return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
 
584
  })
585
  }
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  // Reset at start of each line — no cell rendered yet
588
  lastRenderedStyleId = -1
589
 
 
635
  currentHyperlink,
636
  undefined,
637
  )
638
+ // Emit cursor-hide + bare APC/DCS + cursor-show after cell content.
639
+ // The cursor is already at the CR+LF row position — no CUP needed.
640
+ {
641
+ const rw = frame.screen.rawWritesAtRow.get(y)
642
+ if (rw) {
643
+ screen.diff.push({
644
+ type: 'stdout',
645
+ content: '\x1b[?25l' + rw + '\x1b[?25h',
646
+ })
647
+ screen.txn(prev => [[], { dx: -prev.x, dy: y - prev.y }])
648
+ }
649
+ }
650
+
651
  // CR+LF at end of row — \r resets to column 0, \n moves to next line.
652
  // Without \r, the terminal cursor stays at whatever column content ended
653
  // (since we skip trailing spaces, this can be mid-row).
src/ink/output.ts CHANGED
@@ -730,17 +730,15 @@ function writeLineToScreen(
730
  const ci = characters[i]
731
  if (ci) apc += ci.value
732
  }
733
- // Append to existing entry (multi-chunk APC) or create new one.
734
- // First chunk includes CUP positioning; subsequent chunks append
735
- // the next \x1b_G...\x1b\\ without redundant cursor movement.
 
736
  const existing = screen.rawWritesAtRow.get(y)
737
  if (existing) {
738
  screen.rawWritesAtRow.set(y, existing + apc)
739
  } else {
740
- screen.rawWritesAtRow.set(
741
- y,
742
- `\x1b[${y + 1};${offsetX + 1}H${apc}`,
743
- )
744
  }
745
  } else if (
746
  nextChar === ']' ||
@@ -786,10 +784,7 @@ function writeLineToScreen(
786
  const ci = characters[i]
787
  if (ci) dcs += ci.value
788
  }
789
- screen.rawWritesAtRow.set(
790
- y,
791
- `\x1b[${y + 1};${offsetX + 1}H${dcs}`,
792
- )
793
  charIdx = dcsEnd
794
  }
795
  } else if (
 
730
  const ci = characters[i]
731
  if (ci) apc += ci.value
732
  }
733
+ // Append or create raw write entry. Store the bare APC the
734
+ // cursor is already at the correct screen-buffer position when
735
+ // this entry is emitted (via CR+LF in renderFrameSlice or
736
+ // moveCursorTo in diffEach).
737
  const existing = screen.rawWritesAtRow.get(y)
738
  if (existing) {
739
  screen.rawWritesAtRow.set(y, existing + apc)
740
  } else {
741
+ screen.rawWritesAtRow.set(y, apc)
 
 
 
742
  }
743
  } else if (
744
  nextChar === ']' ||
 
784
  const ci = characters[i]
785
  if (ci) dcs += ci.value
786
  }
787
+ screen.rawWritesAtRow.set(y, dcs)
 
 
 
788
  charIdx = dcsEnd
789
  }
790
  } else if (
src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -1,9 +1,10 @@
1
  import { readFile } from 'fs/promises'
2
  import { homedir } from 'os'
3
- import React from 'react'
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
6
  import { wrapForMultiplexer } from '../../ink/termio/osc.js'
 
7
  import { buildTool, type ToolDef } from '../../Tool.js'
8
  import { logForDebugging } from '../../utils/debug.js'
9
  import {
@@ -44,6 +45,29 @@ function getToolUseSummary(input: Partial<Input>): string | null {
44
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
45
  }
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  export const ImageShowTool = buildTool({
48
  name: IMAGE_TOOL_NAME,
49
  description:
@@ -140,16 +164,15 @@ export const ImageShowTool = buildTool({
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.
@@ -264,13 +287,14 @@ export const ImageShowTool = buildTool({
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
  }
 
1
  import { readFile } from 'fs/promises'
2
  import { homedir } from 'os'
3
+ import React, { useContext, useEffect } from 'react'
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
6
  import { wrapForMultiplexer } from '../../ink/termio/osc.js'
7
+ import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
8
  import { buildTool, type ToolDef } from '../../Tool.js'
9
  import { logForDebugging } from '../../utils/debug.js'
10
  import {
 
45
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
46
  }
47
 
48
+ /**
49
+ * Renders a Kitty-protocol image by writing the escape sequence directly
50
+ * to the terminal via writeRaw, bypassing Ink's virtual DOM. Reserves
51
+ * vertical space with empty RawAnsi lines so Ink doesn't overwrite the
52
+ * image area.
53
+ */
54
+ function KittyImage({ kittyOutput, imageRows }: { kittyOutput: string; imageRows: number }) {
55
+ const writeRaw = useContext(TerminalWriteContext)
56
+
57
+ useEffect(() => {
58
+ if (writeRaw && kittyOutput) {
59
+ // After Ink renders the reserved empty lines, cursor is at the bottom.
60
+ // Move cursor up to the top so the image renders at the start of the
61
+ // reserved space.
62
+ writeRaw(`\x1b[${imageRows}A${kittyOutput}`)
63
+ }
64
+ }, [kittyOutput, imageRows, writeRaw])
65
+
66
+ const width = process.stdout.columns ?? 80
67
+ const lines = new Array<string>(imageRows).fill('')
68
+ return <RawAnsi lines={lines} width={width} />
69
+ }
70
+
71
  export const ImageShowTool = buildTool({
72
  name: IMAGE_TOOL_NAME,
73
  description:
 
164
  return null
165
  }
166
 
167
+ // Kitty protocol: bypass Ink's virtual DOM via writeRaw and reserve
168
+ // space with empty RawAnsi lines.
 
 
 
169
  if (content.kittyOutput && content.imageRows && content.imageRows > 0) {
170
+ return (
171
+ <KittyImage
172
+ kittyOutput={content.kittyOutput}
173
+ imageRows={content.imageRows}
174
+ />
175
+ )
176
  }
177
 
178
  // Block-mode fallback when Kitty protocol is not supported.
 
287
  if (protocol === 'kitty') {
288
  const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
289
  if (rawKitty) {
290
+ // Pass the raw timg output directly through writeRaw — no need to
291
+ // strip cursor sequences since writeRaw bypasses Ink's screen buffer.
292
+ kittyOutput = wrapForMultiplexer(rawKitty.trimEnd())
293
  imageRows = getImageRowsCount(buffer, format)
294
  logForDebugging(
295
  `ImageShow: generated Kitty protocol output, image rows = ${imageRows}`,
296
  )
297
  }
 
298
  if (imageRows === 0) {
299
  kittyOutput = undefined
300
  }