# NousResearch/hermes-agent · Terminal resize causes ghost lines and rendering corruption

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #30775: fix(tui): stabilize resize and IME rendering

A cluster of bugs and fixes centered on terminal resize events producing ghost lines, duplicated status bars, corrupted glyphs, and render loops in both CLI and TUI modes. Work spans detection, clearing, and reflow hardening across multiple terminal emulators.

## TL;DR

- 7 member(s): 0 open issue(s), 3 closed issue(s), 1 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #25976. Verify it addresses your case.
- **Recommended:** read PR #25976 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.22`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #17961: \[Bug\]: TUI resize causes ghost copies (same root cause as #5474 but for Ink/TUI)
- #18449: bug(ui-tui): rapid terminal resize can still leave corrupted glyphs / scattered letters
- #22976: TUI: Terminal resize accumulates blank separator lines

## Prior & competing PRs

- **#25976: fix(ui-tui): heal same-dimension alt-screen resize drift (salvage #18499)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts`, `ui-tui/packages/hermes-ink/src/ink/ink.tsx`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -73,6 +73,8 @@
         "30397170+1000Delta@users.noreply.github.com": "1000Delta",
         "[redacted:email]": "szymonclawd",
         "257759490+szymonclawd@users.noreply.github.com": "szymonclawd",
    +    "[redacted:email]": "luoyuctl",
    +    "51604064+luoyuctl@users.noreply.github.com": "luoyuctl",
         "127238744+teknium1@users.noreply.github.com": "teknium1",
         "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0",
         "97489706+purzbeats@users.noreply.github.com": "purzbeats",
    ```
  - `ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts`

    ```diff
    @@ -0,0 +1,50 @@
    +import { EventEmitter } from 'events'
    +import React from 'react'
    +import { describe, expect, it } from 'vitest'
    +
    +import Text from './components/Text.js'
    +import Ink from './ink.js'
    +import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'
    +
    +class FakeTty extends EventEmitter {
    +  chunks: string[] = []
    +  columns = 20
    +  rows = 5
    +  isTTY = true
    +
    +  write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
    +    this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
    +    cb?.()
    +    return true
    +  }
    +}
    +
    +const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
    +
    +describe('Ink resize healing', () => {
    +  it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {
    +    const stdout = new FakeTty()
    +    const stdin = new FakeTty()
    +    const stderr = new FakeTty()
    +    const ink = new Ink({
    +      exitOnCtrlC: false,
    +      patchConsole: false,
    +      stderr: stderr as unknown as NodeJS.WriteStream,
    +      stdin: stdin as unknown as NodeJS.ReadStream,
    +      stdout: stdout as unknown as NodeJS.WriteStream
    +    })
    +
    +    ink.setAltScreenActive(true)
    +    ink.render(React.createElement(Text, null, 'hello'))
    +    ink.onRender()
    +    stdout.chunks = []
    +
    +    stdout.emit('resize')
    +    ink.onRender()
    +    await tick()
    +
    +    expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME)
    +
    +    ink.unmount()
    +  })
    +})
    ```
  - `ui-tui/packages/hermes-ink/src/ink/ink.tsx`

    ```diff
    @@ -484,17 +484,22 @@ export default class Ink {
       private handleResize = () => {
         const cols = this.options.stdout.columns || 80
         const rows = this.options.stdout.rows || 24
    -
    -    // Terminals often emit 2+ resize events for one user action (window
    -    // settling). Same-dimension events are no-ops; skip to avoid redundant
    -    // frame resets and renders.
    -    if (cols === this.terminalColumns && rows === this.terminalRows) {
    +    const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows
    +
    +    // Terminals often emit 2+ resize events for one user action
    +    // (window settling). Same-dimension events are usually no-ops,
    +    // but in alt-screen mode a same-dimension resize can signal a
    +    // terminal host reflow or buffer restore that leaves stale glyphs
    +    // on the physical screen — treat it as a repaint signal.
    +    if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) {
           return
         }
     
    -    this.terminalColumns = cols
    -    this.terminalRows = rows
    -    this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
    +    if (dimsChanged) {
    +      this.terminalColumns = cols
    +      this.terminalRows = rows
    +      this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
    +    }
     
         // Pending throttled/drain work captured stale dims — cancel so
         // the upcoming microtask owns the next frame.
    ```

    ```diff
    @@ -521,26 +526,7 @@ export default class Ink {
         // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below
         // can take ~80ms; erasing first leaves the screen blank that whole time.
         if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
    -      if (this.altScreenMouseTracking) {
    -        this.options.stdout.write(ENABLE_MOUSE_TRACKING)
    -      }
    -
    -      this.resetFramesForAltScreen()
    -      this.needsEraseBeforePaint = true
    -
    -      // One last repaint after the resize burst settles closes any host-side
    -      // reflow drift the normal diff path can't see.
    -      this.resizeSettleTimer = setTimeout(() => {
    -        this.resizeSettleTimer = null
    -
    -        if (!this.canAltScreenRepaint()) {
    -          return
    -        }
    -
    -        this.resetFramesForAltScreen()
    -        this.needsEraseBeforePaint = true
    -        this.render(this.currentNode!)
    -      }, 160)
    +      this.prepareAltScreenResizeRepaint()
         }
     
         // Already queued: later events in this burst updated dims/alt-screen
    ```

    ```diff
    @@ -573,6 +559,36 @@ export default class Ink {
         )
       }
     
    +  private prepareAltScreenResizeRepaint(): void {
    +    // Clear any pending settle timer from a previous resize burst so
    +    // rapid events don't stack redundant delayed repaints. (handleResize
    +    // also clears this, but the defensive clear keeps the method safe
    +    // if it's ever called from other code paths.)
    +    if (this.resizeSettleTimer !== null) {
    +      clearTimeout(this.resizeSettleTimer)
    +      this.resizeSettleTimer = null
    +    }
    +
    +    if (this.altScreenMouseTracking) {
    +      this.options.stdout.write(ENABLE_MOUSE_TRACKING)
    +    }
    +
    +    this.resetFramesForAltScreen()
    +    this.needsEraseBeforePaint = true
    +
    +    this.resizeSettleTimer = setTimeout(() => {
    +      this.resizeSettleTimer = null
    +
    +      if (!this.canAltScreenRepaint()) {
    +        return
    +      }
    +
    +      this.resetFramesForAltScreen()
    +      this.needsEraseBeforePaint = true
    +      this.render(this.currentNode!)
    +    }, 160)
    +  }
    +
       resolveExitPromise: () => void = () => {}
       rejectExitPromise: (reason?: Error) => void = () => {}
       unsubscribeExit: () => void = () => {}
    ```

    ```diff
    @@ -919,8 +935,9 @@ export default class Ink {
         const optimized = optimize(diff)
         const optimizeMs = performance.now() - tOptimize
         const hasDiff = optimized.length > 0
    +    const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint
     
    -    if (this.altScreenActive && hasDiff) {
    +    if (this.altScreenActive && (hasDiff || needsAltScreenErase)) {
           // Prepend CSI H to anchor the physical cursor to (0,0) so
           // log-update's relative moves compute from a known spot (self-healing
           // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR
    ```

    ```diff
    @@ -940,7 +957,7 @@ export default class Ink {
           // resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
           // supported, the clear+paint lands atomically; otherwise the final state
           // is still healed even if the repaint is visible.
    -      if (this.needsEraseBeforePaint) {
    +      if (needsAltScreenErase) {
             this.needsEraseBeforePaint = false
             optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
           } else {
    ```

    ```diff
    @@ -1062,7 +1079,7 @@ export default class Ink {
         this.lastDrainMs = 0
     
         // Only track drain on TTY. Piped/non-TTY stdout bypasses flow control.
    -    const trackDrain = this.options.stdout.isTTY && hasDiff
    +    const trackDrain = this.options.stdout.isTTY && optimized.length > 0
         const drainStart = trackDrain ? tWrite : 0
     
         if (trackDrain) {
    ```
- **#18499: fix(ui-tui): heal same-dimension alt-screen resize drift**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#24164`
- **#23034: fix(tui): clear terminal on resize to prevent ghost separator lines**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `1e445b254`, `279b656ad`, `3e01de0b0`, `f7e86577b`, `#25976`
- **#30775: fix(tui): stabilize resize and IME rendering**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `d2c7760ceb8d1fa46a95e215040cb5655337ffb7`
  - files: `ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx`, `ui-tui/packages/hermes-ink/src/ink/ink.tsx`, `ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts`, `ui-tui/packages/hermes-ink/src/ink/scrollbox-overflow.test.tsx`, `ui-tui/src/__tests__/textInputWrap.test.ts`, `ui-tui/src/__tests__/virtualHeights.test.ts`, `ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts`, `ui-tui/src/app/useMainApp.ts`, `ui-tui/src/components/appLayout.tsx`, `ui-tui/src/components/textInput.tsx`, `ui-tui/src/hooks/useVirtualHistory.ts`, `ui-tui/src/lib/inputMetrics.ts`, `ui-tui/src/lib/virtualHeights.ts`

## Fix status

- Merged to main in PR #25976 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-14`.
- Subsystem: `environments`
