error
Browse files- src/ink/log-update.ts +56 -28
- src/tools/ImageShowTool/ImageShowTool.tsx +34 -24
- src/utils/terminalImage.ts +57 -0
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 |
-
//
|
| 142 |
-
//
|
| 143 |
-
//
|
| 144 |
-
//
|
| 145 |
-
//
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if (content.timgOutput) {
|
| 148 |
const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
|
| 149 |
-
|
| 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 |
-
//
|
| 258 |
-
// 1. Kitty protocol via timg for native-quality
|
| 259 |
-
// 2. Block-mode via timg
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
}
|
| 270 |
}
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
| 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
|