From 7ac7485d2bde6f72b148c4dc7ac58100bee5aa12 Mon Sep 17 00:00:00 2001 From: Erik Zwep Date: Mon, 26 Oct 2026 16:51:43 +0100 Subject: [PATCH 1/2] fix(terminal): stop the find bar from crashing a node after a narrowing resize xterm trims buffer lines on a narrowing resize only for a buffer that reflows, and the alternate buffer (every Claude Code node) never does. The search addon then reads the cut-off cells, computes a negative decoration width for a hit past the right edge, and registerDecoration throws 'This API only accepts positive integers' inside the find-bar effect, so the node's error boundary replaces the whole node. patchShrinkTrim trims the alternate buffer's lines to the new width on a narrowing resize, the same thing xterm does to a reflowing buffer. Wired into TerminalNode and the kanban ModalTerminal. --- .../components/kanban/ModalTerminal.tsx | 3 + src/renderer/nodes/TerminalNode.tsx | 4 + src/renderer/terminal/shrink-trim.test.ts | 126 ++++++++++++++++++ src/renderer/terminal/shrink-trim.ts | 82 ++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/renderer/terminal/shrink-trim.test.ts create mode 100644 src/renderer/terminal/shrink-trim.ts diff --git a/src/renderer/components/kanban/ModalTerminal.tsx b/src/renderer/components/kanban/ModalTerminal.tsx index 0ae895b65..8bd2c85e4 100644 --- a/src/renderer/components/kanban/ModalTerminal.tsx +++ b/src/renderer/components/kanban/ModalTerminal.tsx @@ -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' @@ -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 diff --git a/src/renderer/nodes/TerminalNode.tsx b/src/renderer/nodes/TerminalNode.tsx index 940f9544a..54b8e8d5a 100644 --- a/src/renderer/nodes/TerminalNode.tsx +++ b/src/renderer/nodes/TerminalNode.tsx @@ -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, @@ -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 diff --git a/src/renderer/terminal/shrink-trim.test.ts b/src/renderer/terminal/shrink-trim.test.ts new file mode 100644 index 000000000..f460608a2 --- /dev/null +++ b/src/renderer/terminal/shrink-trim.test.ts @@ -0,0 +1,126 @@ +// @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 => 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('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\)/) + }) +}) diff --git a/src/renderer/terminal/shrink-trim.ts b/src/renderer/terminal/shrink-trim.ts new file mode 100644 index 000000000..bcda7dc20 --- /dev/null +++ b/src/renderer/terminal/shrink-trim.ts @@ -0,0 +1,82 @@ +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, and walking up to 50k scrollback lines per resize would be waste. + trimBuffer(core.buffers?.alt, next) + } catch { + // Fail open: an untrimmed line is the stock behavior, never worse. + } + }) + return true + } catch { + return false + } +} From 26ead9f1078d35ff2a87faff7aef4023803b515a Mon Sep 17 00:00:00 2001 From: Erik Zwep Date: Tue, 27 Oct 2026 00:20:33 +0100 Subject: [PATCH 2/2] test(terminal): pin the trim with a wide character on the new edge; note why only the alt buffer --- src/renderer/terminal/shrink-trim.test.ts | 11 +++++++++++ src/renderer/terminal/shrink-trim.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/terminal/shrink-trim.test.ts b/src/renderer/terminal/shrink-trim.test.ts index f460608a2..64727ef1d 100644 --- a/src/renderer/terminal/shrink-trim.test.ts +++ b/src/renderer/terminal/shrink-trim.test.ts @@ -104,6 +104,17 @@ describe('patchShrinkTrim', () => { 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) diff --git a/src/renderer/terminal/shrink-trim.ts b/src/renderer/terminal/shrink-trim.ts index bcda7dc20..80baa3bfa 100644 --- a/src/renderer/terminal/shrink-trim.ts +++ b/src/renderer/terminal/shrink-trim.ts @@ -69,7 +69,9 @@ export function patchShrinkTrim(term: Terminal): boolean { if (!narrowed) return try { // Only the alternate buffer: the normal one reflows whenever it has scrollback, which - // xterm trims itself, and walking up to 50k scrollback lines per resize would be waste. + // 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.