Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/renderer/components/kanban/ModalTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isLocalOnSshCanvas } from '../../lib/localOnSshCanvas'
import { ptyRefusal } from '@shared/pty-refusal'

import { patchImeModeSwitch } from '../../terminal/ime-mode-switch'
import { patchShrinkTrim } from '../../terminal/shrink-trim'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
Expand Down Expand Up @@ -315,6 +316,8 @@ export function ModalTerminal({
// Renderer-parity with the canvas terminals (see char-size-quantize): the modal co-views
// the same session, so its column math must match what the canvas draws.
quantizeCharSize(term)
// The card's find bar crashes on an untrimmed alternate buffer, same as the canvas node.
patchShrinkTrim(term)
fit.fit()

let sessionId: string | null = null
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/nodes/TerminalNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
import { quantizeCharSize } from '../terminal/char-size-quantize'
import { resyncDomRendererSpacing } from '../terminal/dom-renderer-spacing'
import { patchViewportScrollSync } from '../terminal/viewport-scroll-sync'
import { patchShrinkTrim } from '../terminal/shrink-trim'
import {
parkCap,
parkWindowMs,
Expand Down Expand Up @@ -3250,6 +3251,9 @@ export function TerminalNode({
// grant/release swaps renderers without the text visibly reflowing (see the helper).
quantizeCharSize(term)
patchViewportScrollSync(term)
// Before the first fit: a narrowing resize must trim the alternate buffer, or the find bar
// can crash this node (see the helper).
patchShrinkTrim(term)
applyFit()
// Reads the STORE, not the component's focusedRef: a parked terminal keeps the closure
// from the instance that created it, and a ref from a dead instance never updates — the
Expand Down
137 changes: 137 additions & 0 deletions src/renderer/terminal/shrink-trim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
// "This node hit an error — This API only accepts positive integers" when typing in the find bar of
// a Claude node. Claude Code draws in the ALTERNATE screen, and xterm trims a buffer's lines on a
// narrowing resize only when that buffer reflows (`Buffer.resize`, `_isReflowEnabled`) — the
// alternate buffer has no scrollback, so it never does. Its lines keep their old length, the search
// addon reads the cells past the right edge as text, and for a hit there its decoration width
// (`cols - col`) goes negative: `registerDecoration` throws, and the node's error boundary takes the
// whole node down. These drive a REAL xterm and the real search addon, so the failure is theirs.
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { Terminal } from '@xterm/xterm'
import { SearchAddon } from '@xterm/addon-search'
import { patchShrinkTrim } from './shrink-trim'

const DECORATIONS = {
matchBackground: '#ffd60a55',
activeMatchBackground: '#ff9f0a',
matchOverviewRuler: '#ffd60a',
activeMatchColorOverviewRuler: '#ff9f0a'
}
const ALT_SCREEN = '\x1b[?1049h'

let host: HTMLDivElement | undefined
afterEach(() => host?.remove())

function open(cols: number): { term: Terminal; search: SearchAddon } {
;(window as unknown as { matchMedia?: unknown }).matchMedia ??= () => ({
matches: false,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {}
})
host = document.createElement('div')
document.body.appendChild(host)
const term = new Terminal({ cols, rows: 5, allowProposedApi: true })
const search = new SearchAddon()
term.loadAddon(search)
term.open(host)
return { term, search }
}

const write = (term: Terminal, data: string): Promise<void> => new Promise((r) => term.write(data, r))

/** Run a find the way the find bar does and report how many hits xterm counted. */
function find(search: SearchAddon, query: string): number {
let count = -1
const sub = search.onDidChangeResults((r) => (count = r.resultCount))
try {
search.findNext(query, { decorations: DECORATIONS, incremental: true })
} finally {
sub.dispose()
}
return count
}

describe('patchShrinkTrim', () => {
it('reproduces the crash without the patch (guards the test itself)', async () => {
const { term, search } = open(130)
await write(term, ALT_SCREEN + 'x'.repeat(120) + ' s')
term.resize(100, 5)
expect(() => find(search, 's')).toThrow('This API only accepts positive integers')
})

it('a find after narrowing an alternate-screen terminal no longer throws', async () => {
const { term, search } = open(130)
patchShrinkTrim(term)
await write(term, ALT_SCREEN + 'x'.repeat(120) + ' s')
term.resize(100, 5)
expect(() => find(search, 's')).not.toThrow()
})

it('text cut off by the narrowing is no longer counted as a hit', async () => {
const { term, search } = open(130)
patchShrinkTrim(term)
await write(term, ALT_SCREEN + 'x'.repeat(120) + ' s')
term.resize(100, 5)
expect(find(search, 's')).toBe(0)
})

it('hits that are still on screen are found and highlighted as before', async () => {
const { term, search } = open(130)
patchShrinkTrim(term)
await write(term, ALT_SCREEN + 'a s b' + 'x'.repeat(110) + ' s')
term.resize(100, 5)
expect(find(search, 's')).toBe(1)
})

it('the normal buffer needs no patch: xterm trims it itself', async () => {
const { term, search } = open(130)
await write(term, 'x'.repeat(120) + ' s')
term.resize(100, 5)
expect(() => find(search, 's')).not.toThrow()
expect(term.buffer.active.getLine(0)!.translateToString(false)).toHaveLength(100)
})

it('widening again shows blank cells, not the text that was cut off', async () => {
const { term } = open(130)
patchShrinkTrim(term)
await write(term, ALT_SCREEN + 'x'.repeat(120) + ' s')
term.resize(100, 5)
term.resize(130, 5)
expect(term.buffer.active.getLine(0)!.translateToString(true)).toBe('x'.repeat(100))
})

it('a wide character cut by the new edge does not break the trim or the find', async () => {
const { term, search } = open(130)
patchShrinkTrim(term)
// '日' is two cells wide and lands on columns 99–100: the narrowing to 100 cuts through it.
await write(term, ALT_SCREEN + 'x'.repeat(99) + '日 s')
term.resize(100, 5)
expect(() => find(search, 's')).not.toThrow()
expect(find(search, '日')).toBe(1)
expect(term.buffer.active.getLine(0)!.length).toBe(100)
})

it('is idempotent and fails open on a terminal without the expected internals', async () => {
const { term, search } = open(130)
expect(patchShrinkTrim(term)).toBe(true)
expect(patchShrinkTrim(term)).toBe(true)
await write(term, ALT_SCREEN + 'x'.repeat(120) + ' s')
term.resize(100, 5)
expect(() => find(search, 's')).not.toThrow()
expect(patchShrinkTrim({} as Terminal)).toBe(false)
})
})

describe('wiring', () => {
const src = (rel: string): string => readFileSync(resolve(__dirname, rel), 'utf8')
it.each([
['TerminalNode', '../nodes/TerminalNode.tsx'],
['ModalTerminal', '../components/kanban/ModalTerminal.tsx']
])('%s patches every terminal it creates', (_name, rel) => {
expect(src(rel)).toMatch(/patchShrinkTrim\(term\)/)
})
})
84 changes: 84 additions & 0 deletions src/renderer/terminal/shrink-trim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { Terminal } from '@xterm/xterm'

/**
* Trims every buffer line to the terminal's width after a narrowing resize, which xterm only does
* for a buffer that reflows.
*
* `Buffer.resize` cuts the cells past the new right edge inside its `_isReflowEnabled` branch, and
* reflow needs scrollback: the ALTERNATE buffer never has any, so its lines keep their old length
* (still true on xterm.js master). Nothing draws those cells, but two things read them:
*
* 1. **The search addon.** It reads each line to its stored length, finds text that is no longer on
* screen, and for a hit past the edge computes a decoration width of `cols - col` — negative.
* `registerDecoration` throws "This API only accepts positive integers", and since the find bar
* runs `findNext` inside a React effect, the node's error boundary replaced the whole node with
* an error card. Every Claude Code node is on the alternate screen, so narrowing one node and
* then searching in it was enough. Measured with xterm 5.5.0 + addon-search 0.16.0.
* 2. **A later widening.** `BufferLine.resize` grows a line over its old cells, so widening back
* brought the cut-off text back on screen until the app repainted the row.
*
* Trimming is what xterm already does to a reflowing buffer, and a trimmed cell is one nothing
* displays, so for the visible screen this changes nothing.
*
* Guarded and fail-open in the same style as `patchViewportScrollSync`: if a private field is
* missing on a future xterm version, the patch leaves stock behavior intact.
*/
interface BufferLineLike {
length: number
resize(cols: number, fillCellData: unknown): boolean
}

interface BufferLike {
lines: { length: number; get(index: number): BufferLineLike | undefined }
getNullCell(): unknown
}

interface CoreLike {
buffers?: { normal?: BufferLike; alt?: BufferLike }
__ntShrinkTrimPatched?: boolean
}

function trimBuffer(buffer: BufferLike | undefined, cols: number): void {
if (!buffer) return
const fill = buffer.getNullCell()
for (let i = 0; i < buffer.lines.length; i++) {
const line = buffer.lines.get(i)
if (line && line.length > cols) line.resize(cols, fill)
}
}

export function patchShrinkTrim(term: Terminal): boolean {
try {
const core = (term as unknown as { _core?: CoreLike })._core
const buffers = core?.buffers
if (
!core ||
!buffers ||
typeof buffers.alt?.getNullCell !== 'function' ||
typeof buffers.alt.lines?.get !== 'function' ||
typeof term.onResize !== 'function'
) {
return false
}
if (core.__ntShrinkTrimPatched) return true
core.__ntShrinkTrimPatched = true
let cols = term.cols
term.onResize(({ cols: next }) => {
const narrowed = next < cols
cols = next
if (!narrowed) return
try {
// Only the alternate buffer: the normal one reflows whenever it has scrollback, which
// xterm trims itself. Our terminals never set `scrollback: 0` or `windowsPty` (the two
// things that turn reflow off), so walking the normal buffer here would only be waste.
// `onResize` fires synchronously right after `buffers.resize`, before any render.
trimBuffer(core.buffers?.alt, next)
} catch {
// Fail open: an untrimmed line is the stock behavior, never worse.
}
})
return true
} catch {
return false
}
}
Loading