refactor: emit native image sequences after cell content with cursor hide/show
Browse filesMove 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 +31 -41
- src/ink/output.ts +6 -11
- src/tools/ImageShowTool/ImageShowTool.tsx +36 -12
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
|
| 734 |
-
//
|
| 735 |
-
//
|
|
|
|
| 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 |
-
//
|
| 144 |
-
//
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
}
|