{"repo":"NousResearch/hermes-agent","public_id":"17961","display_label":"Terminal resize causes ghost lines and rendering corruption","summary":"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.","subsystem":"environments","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":3,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25976,"merged_at":"2026-05-14T22:25:59+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-14T22:25:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22476190476190475},"fix_edges":[{"issue_number":22976,"pr_number":25976,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-14T22:25:59+00:00"}],"members":[{"kind":"issue","number":17961,"title":"[Bug]: TUI resize causes ghost copies (same root cause as #5474 but for Ink/TUI)","url":null,"state":"closed"},{"kind":"issue","number":18449,"title":"bug(ui-tui): rapid terminal resize can still leave corrupted glyphs / scattered letters","url":null,"state":"closed"},{"kind":"issue","number":22976,"title":"TUI: Terminal resize accumulates blank separator lines","url":null,"state":"closed"},{"kind":"pr","number":18499,"title":"fix(ui-tui): heal same-dimension alt-screen resize drift","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24164","number":24164}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":23034,"title":"fix(tui): clear terminal on resize to prevent ghost separator lines","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1e445b254","number":0},{"kind":"commit","value":"279b656ad","number":0},{"kind":"commit","value":"3e01de0b0","number":0},{"kind":"commit","value":"f7e86577b","number":0},{"kind":"pr","value":"#25976","number":25976}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25976,"title":"fix(ui-tui): heal same-dimension alt-screen resize drift (salvage #18499)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"scripts/release.py","hunks":["@@ -73,6 +73,8 @@\n     \"30397170+1000Delta@users.noreply.github.com\": \"1000Delta\",\n     \"[redacted:email]\": \"szymonclawd\",\n     \"257759490+szymonclawd@users.noreply.github.com\": \"szymonclawd\",\n+    \"[redacted:email]\": \"luoyuctl\",\n+    \"51604064+luoyuctl@users.noreply.github.com\": \"luoyuctl\",\n     \"127238744+teknium1@users.noreply.github.com\": \"teknium1\",\n     \"147827411+EloquentBrush@users.noreply.github.com\": \"AhmetArif0\",\n     \"97489706+purzbeats@users.noreply.github.com\": \"purzbeats\","]},{"file":"ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts","hunks":["@@ -0,0 +1,50 @@\n+import { EventEmitter } from 'events'\n+import React from 'react'\n+import { describe, expect, it } from 'vitest'\n+\n+import Text from './components/Text.js'\n+import Ink from './ink.js'\n+import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'\n+\n+class FakeTty extends EventEmitter {\n+  chunks: string[] = []\n+  columns = 20\n+  rows = 5\n+  isTTY = true\n+\n+  write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {\n+    this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))\n+    cb?.()\n+    return true\n+  }\n+}\n+\n+const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))\n+\n+describe('Ink resize healing', () => {\n+  it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {\n+    const stdout = new FakeTty()\n+    const stdin = new FakeTty()\n+    const stderr = new FakeTty()\n+    const ink = new Ink({\n+      exitOnCtrlC: false,\n+      patchConsole: false,\n+      stderr: stderr as unknown as NodeJS.WriteStream,\n+      stdin: stdin as unknown as NodeJS.ReadStream,\n+      stdout: stdout as unknown as NodeJS.WriteStream\n+    })\n+\n+    ink.setAltScreenActive(true)\n+    ink.render(React.createElement(Text, null, 'hello'))\n+    ink.onRender()\n+    stdout.chunks = []\n+\n+    stdout.emit('resize')\n+    ink.onRender()\n+    await tick()\n+\n+    expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME)\n+\n+    ink.unmount()\n+  })\n+})"]},{"file":"ui-tui/packages/hermes-ink/src/ink/ink.tsx","hunks":["@@ -484,17 +484,22 @@ export default class Ink {\n   private handleResize = () => {\n     const cols = this.options.stdout.columns || 80\n     const rows = this.options.stdout.rows || 24\n-\n-    // Terminals often emit 2+ resize events for one user action (window\n-    // settling). Same-dimension events are no-ops; skip to avoid redundant\n-    // frame resets and renders.\n-    if (cols === this.terminalColumns && rows === this.terminalRows) {\n+    const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows\n+\n+    // Terminals often emit 2+ resize events for one user action\n+    // (window settling). Same-dimension events are usually no-ops,\n+    // but in alt-screen mode a same-dimension resize can signal a\n+    // terminal host reflow or buffer restore that leaves stale glyphs\n+    // on the physical screen — treat it as a repaint signal.\n+    if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) {\n       return\n     }\n \n-    this.terminalColumns = cols\n-    this.terminalRows = rows\n-    this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)\n+    if (dimsChanged) {\n+      this.terminalColumns = cols\n+      this.terminalRows = rows\n+      this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)\n+    }\n \n     // Pending throttled/drain work captured stale dims — cancel so\n     // the upcoming microtask owns the next frame.","@@ -521,26 +526,7 @@ export default class Ink {\n     // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below\n     // can take ~80ms; erasing first leaves the screen blank that whole time.\n     if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {\n-      if (this.altScreenMouseTracking) {\n-        this.options.stdout.write(ENABLE_MOUSE_TRACKING)\n-      }\n-\n-      this.resetFramesForAltScreen()\n-      this.needsEraseBeforePaint = true\n-\n-      // One last repaint after the resize burst settles closes any host-side\n-      // reflow drift the normal diff path can't see.\n-      this.resizeSettleTimer = setTimeout(() => {\n-        this.resizeSettleTimer = null\n-\n-        if (!this.canAltScreenRepaint()) {\n-          return\n-        }\n-\n-        this.resetFramesForAltScreen()\n-        this.needsEraseBeforePaint = true\n-        this.render(this.currentNode!)\n-      }, 160)\n+      this.prepareAltScreenResizeRepaint()\n     }\n \n     // Already queued: later events in this burst updated dims/alt-screen","@@ -573,6 +559,36 @@ export default class Ink {\n     )\n   }\n \n+  private prepareAltScreenResizeRepaint(): void {\n+    // Clear any pending settle timer from a previous resize burst so\n+    // rapid events don't stack redundant delayed repaints. (handleResize\n+    // also clears this, but the defensive clear keeps the method safe\n+    // if it's ever called from other code paths.)\n+    if (this.resizeSettleTimer !== null) {\n+      clearTimeout(this.resizeSettleTimer)\n+      this.resizeSettleTimer = null\n+    }\n+\n+    if (this.altScreenMouseTracking) {\n+      this.options.stdout.write(ENABLE_MOUSE_TRACKING)\n+    }\n+\n+    this.resetFramesForAltScreen()\n+    this.needsEraseBeforePaint = true\n+\n+    this.resizeSettleTimer = setTimeout(() => {\n+      this.resizeSettleTimer = null\n+\n+      if (!this.canAltScreenRepaint()) {\n+        return\n+      }\n+\n+      this.resetFramesForAltScreen()\n+      this.needsEraseBeforePaint = true\n+      this.render(this.currentNode!)\n+    }, 160)\n+  }\n+\n   resolveExitPromise: () => void = () => {}\n   rejectExitPromise: (reason?: Error) => void = () => {}\n   unsubscribeExit: () => void = () => {}","@@ -919,8 +935,9 @@ export default class Ink {\n     const optimized = optimize(diff)\n     const optimizeMs = performance.now() - tOptimize\n     const hasDiff = optimized.length > 0\n+    const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint\n \n-    if (this.altScreenActive && hasDiff) {\n+    if (this.altScreenActive && (hasDiff || needsAltScreenErase)) {\n       // Prepend CSI H to anchor the physical cursor to (0,0) so\n       // log-update's relative moves compute from a known spot (self-healing\n       // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR","@@ -940,7 +957,7 @@ export default class Ink {\n       // resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is\n       // supported, the clear+paint lands atomically; otherwise the final state\n       // is still healed even if the repaint is visible.\n-      if (this.needsEraseBeforePaint) {\n+      if (needsAltScreenErase) {\n         this.needsEraseBeforePaint = false\n         optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)\n       } else {","@@ -1062,7 +1079,7 @@ export default class Ink {\n     this.lastDrainMs = 0\n \n     // Only track drain on TTY. Piped/non-TTY stdout bypasses flow control.\n-    const trackDrain = this.options.stdout.isTTY && hasDiff\n+    const trackDrain = this.options.stdout.isTTY && optimized.length > 0\n     const drainStart = trackDrain ? tWrite : 0\n \n     if (trackDrain) {\n"]}]},{"kind":"pr","number":30775,"title":"fix(tui): stabilize resize and IME rendering","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"d2c7760ceb8d1fa46a95e215040cb5655337ffb7","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}