# NousResearch/hermes-agent · TUI status bar layout fixes on terminal resize

## Open now

- Nothing open on GitHub.

These issues and pull requests address visual glitches and layout problems in the TUI status bar that occur during or after terminal resize events, including line fragmentation, ghost lines, column resyncing, and narrow-terminal content prioritization.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #55931. Verify it addresses your case.
- **Recommended:** read PR #55931 (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_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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

- #36666: TUI status bar fragments across lines after terminal resize in Ghostty

## Prior & competing PRs

- **#55931: fix(tui): coalesce drag-resize reflow + harden resize-burst heal coverage**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts`, `ui-tui/src/app/useMainApp.ts`, `ui-tui/src/config/timing.ts`, `ui-tui/src/lib/resizeCoalescer.test.ts`, `ui-tui/src/lib/resizeCoalescer.ts`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts`

    ```diff
    @@ -46,7 +46,105 @@ describe('Ink resize healing', () => {
         ink.onRender()
         await tick()
     
    -    expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME)
    +    // The heal may also erase scrollback (CSI 3J interposed between 2J and H)
    +    // depending on which recovery path runs, so assert the invariant — screen
    +    // erased, then content repainted after — rather than an exact byte run.
    +    const out = stdout.chunks.join('')
    +    expect(out).toContain(ERASE_SCREEN)
    +    expect(out).toContain(CURSOR_HOME)
    +    expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
    +
    +    ink.unmount()
    +  })
    +
    +  // Regression for issue #18449: dragging the terminal back and forth quickly
    +  // emits a BURST of resize events (the single-event test above only covers one
    +  // tick). Each tick resets the frame buffers and arms needsEraseBeforePaint, so
    +  // the burst must still converge to a clean erase+repaint — a stacked event
    +  // must never consume the erase and leave the final paint as a partial diff
    +  // that lets stale glyphs survive.
    +  it('converges to a clean erased frame after a rapid resize burst', 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 = []
    +
    +    // Wobble the dimensions like a drag — widen, shrink, grow rows — then
    +    // settle back on the STARTING geometry. Even though the net dimensions are
    +    // unchanged, a host reflow during the burst can have scattered glyphs, so
    +    // the renderer must still heal rather than treat the end state as a no-op.
    +    const wobble: Array<[number, number]> = [
    +      [30, 5],
    +      [12, 9],
    +      [25, 4],
    +      [20, 5]
    +    ]
    +
    +    for (const [columns, rows] of wobble) {
    +      stdout.columns = columns
    +      stdout.rows = rows
    +      stdout.emit('resize')
    +    }
    +
    +    ink.onRender()
    +    await tick()
    +
    +    // The heal can erase scrollback too (CSI 3J interposed), so assert the
    +    // semantic invariant rather than an exact byte sequence: the screen was
    +    // erased and the content was repainted AFTER the erase — i.e. the final
    +    // frame is a clean repaint, not a partial diff over drifted cells.
    +    const out = stdout.chunks.join('')
    +    expect(out).toContain(ERASE_SCREEN)
    +    expect(out).toContain(CURSOR_HOME)
    +    expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
    +
    +    ink.unmount()
    +  })
    +
    +  // The burst above ends on a same-dimension event; this isolates that worst
    +  // case on its own — a resize event whose dims equal the last known geometry
    +  // (the terminal restored the buffer / reflowed without a net size change)
    +  // must still arm the erase, because the physical screen may carry drift the
    +  // diff path cannot see (see log-update "drift repro").
    +  it('heals a same-dimension resize even when no React commit changes the tree', 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 = []
    +
    +    // Dimensions are identical to the initial render — the tree never changes.
    +    stdout.emit('resize')
    +    ink.onRender()
    +    await tick()
    +
    +    const out = stdout.chunks.join('')
    +    expect(out).toContain(ERASE_SCREEN)
    +    expect(out).toContain(CURSOR_HOME)
    +    expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
     
         ink.unmount()
       })
    ```
  - `ui-tui/src/app/useMainApp.ts`

    ```diff
    @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
     
     import { STARTUP_RESUME_ID } from '../config/env.js'
     import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
    +import { RESIZE_COALESCE_MS } from '../config/timing.js'
     import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
     import { SECTION_NAMES, sectionMode } from '../domain/details.js'
     import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
    ```

    ```diff
    @@ -23,6 +24,7 @@ import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
     import { composerPromptWidth } from '../lib/inputMetrics.js'
     import { appendTranscriptMessage } from '../lib/messages.js'
     import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
    +import { createResizeCoalescer } from '../lib/resizeCoalescer.js'
     import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
     import { terminalParityHints } from '../lib/terminalParity.js'
     import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
    ```

    ```diff
    @@ -145,7 +147,15 @@ export function useMainApp(gw: GatewayClient) {
           return
         }
     
    -    const sync = () => setCols(stdout.columns ?? 80)
    +    // A drag-resize emits a burst of 'resize' events; syncing `cols` on every
    +    // one remounts the visible transcript rows each tick (they're keyed on
    +    // cols so yoga re-measures), turning a smooth drag into a flickering
    +    // remount storm. Coalesce the burst with a leading+trailing throttle: the
    +    // first event reflows immediately (the drag stays responsive), the rest
    +    // collapse to at most one reflow per RESIZE_COALESCE_MS, and the trailing
    +    // edge always applies the final width so the settled layout is exact.
    +    const coalescer = createResizeCoalescer(() => setCols(stdout.columns ?? 80), RESIZE_COALESCE_MS)
    +    const sync = () => coalescer.schedule()
     
         stdout.on('resize', sync)
     
    ```

    ```diff
    @@ -154,6 +164,7 @@ export function useMainApp(gw: GatewayClient) {
         }
     
         return () => {
    +      coalescer.cancel()
           stdout.off('resize', sync)
     
           if (stdout.isTTY) {
    ```
  - `ui-tui/src/config/timing.ts`

    ```diff
    @@ -4,3 +4,11 @@ export const STREAM_SCROLL_BATCH_MS = 96
     export const STREAM_TYPING_BATCH_MS = 80
     export const TYPING_IDLE_MS = 250
     export const REASONING_PULSE_MS = 700
    +
    +// A drag-resize fires a burst of SIGWINCH events (one per pixel step in some
    +// hosts). Each distinct terminal width remounts the visible transcript rows so
    +// yoga re-measures off live geometry, so reflowing on every tick stutters the
    +// drag. Coalesce the burst to at most one reflow per this window (~30fps):
    +// responsive enough to track the drag, cheap enough to stay smooth, and the
    +// trailing edge always lands the final width so the settled layout is exact.
    +export const RESIZE_COALESCE_MS = 32
    ```
  - `ui-tui/src/lib/resizeCoalescer.test.ts`

    ```diff
    @@ -0,0 +1,87 @@
    +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
    +
    +import { createResizeCoalescer } from './resizeCoalescer.js'
    +
    +describe('createResizeCoalescer', () => {
    +  beforeEach(() => {
    +    vi.useFakeTimers()
    +  })
    +
    +  afterEach(() => {
    +    vi.useRealTimers()
    +  })
    +
    +  it('reflows immediately on the first event (leading edge)', () => {
    +    const reflow = vi.fn()
    +    const coalescer = createResizeCoalescer(reflow, 32)
    +
    +    coalescer.schedule()
    +
    +    expect(reflow).toHaveBeenCalledTimes(1)
    +  })
    +
    +  it('collapses a rapid burst into one leading + one trailing reflow', () => {
    +    const reflow = vi.fn()
    +    const coalescer = createResizeCoalescer(reflow, 32)
    +
    +    // A drag: five events inside one 32ms window.
    +    coalescer.schedule()
    +
    +    for (let i = 0; i < 4; i++) {
    +      vi.advanceTimersByTime(5)
    +      coalescer.schedule()
    +    }
    +
    +    // Only the leading edge has fired mid-burst.
    +    expect(reflow).toHaveBeenCalledTimes(1)
    +
    +    // The trailing edge lands the final width once the window settles.
    +    vi.advanceTimersByTime(32)
    +    expect(reflow).toHaveBeenCalledTimes(2)
    +  })
    +
    +  it('reflows immediately again once the interval has elapsed', () => {
    +    const reflow = vi.fn()
    +    const coalescer = createResizeCoalescer(reflow, 32)
    +
    +    coalescer.schedule()
    +    expect(reflow).toHaveBeenCalledTimes(1)
    +
    +    // Gap longer than the window — the next event is a fresh leading edge.
    +    vi.advanceTimersByTime(40)
    +    coalescer.schedule()
    +
    +    expect(reflow).toHaveBeenCalledTimes(2)
    +  })
    +
    +  it('cancel() drops a pending trailing reflow', () => {
    +    const reflow = vi.fn()
    +    const coalescer = createResizeCoalescer(reflow, 32)
    +
    +    coalescer.schedule() // leading
    +    vi.advanceTimersByTime(5)
    +    coalescer.schedule() // schedules a trailing reflow
    +    coalescer.cancel()
    +
    +    vi.advanceTimersByTime(100)
    +    expect(reflow).toHaveBeenCalledTimes(1)
    +  })
    +
    +  it('continuous dragging reflows about once per interval, not per event', () => {
    +    const reflow = vi.fn()
    +    const coalescer = createResizeCoalescer(reflow, 30)
    +
    +    // 30 events over 300ms (one every 10ms) — a sustained drag.
    +    for (let i = 0; i < 30; i++) {
    +      coalescer.schedule()
    +      vi.advanceTimersByTime(10)
    +    }
    +
    +    // Flush the final trailing reflow.
    +    vi.advanceTimersByTime(30)
    +
    +    // ~300ms / 30ms ≈ 10 reflows, not 30. Bound it loosely to stay robust.
    +    expect(reflow.mock.calls.length).toBeLessThanOrEqual(12)
    +    expect(reflow.mock.calls.length).toBeGreaterThanOrEqual(8)
    +  })
    +})
    ```
  - `ui-tui/src/lib/resizeCoalescer.ts`

    ```diff
    @@ -0,0 +1,56 @@
    +export interface ResizeCoalescer {
    +  /** Call on each terminal 'resize' event. */
    +  schedule: () => void
    +  /** Drop any pending trailing reflow (effect cleanup). */
    +  cancel: () => void
    +}
    +
    +/**
    + * Leading + trailing throttle for terminal-resize bursts.
    + *
    + * A drag-resize emits a burst of 'resize' events; reflowing on every one
    + * remounts the visible transcript rows each tick (they're keyed on cols so
    + * yoga re-measures off live geometry), turning a smooth drag into a
    + * flickering remount storm.
    + *
    + * `schedule()` reflows immediately on the first event (the drag stays
    + * responsive), then collapses subsequent events to at most one `reflow()`
    + * per `intervalMs`, and always fires a trailing `reflow()` so the final
    + * width lands exactly. `cancel()` clears a pending trailing reflow.
    + *
    + * Uses `Date.now()` + `setTimeout` directly so it is deterministically
    + * testable under fake timers.
    + */
    +export function createResizeCoalescer(reflow: () => void, intervalMs: number): ResizeCoalescer {
    +  // -Infinity (not 0) so the first schedule() always satisfies the
    +  // elapsed >= interval leading-edge check regardless of the wall clock.
    +  let lastReflow = Number.NEGATIVE_INFINITY
    +  let trailing: ReturnType<typeof setTimeout> | undefined
    +
    +  const run = () => {
    +    lastReflow = Date.now()
    +    reflow()
    +  }
    +
    +  return {
    +    schedule() {
    +      const elapsed = Date.now() - lastReflow
    +
    +      clearTimeout(trailing)
    +      trailing = undefined
    +
    +      if (elapsed >= intervalMs) {
    +        run()
    +      } else {
    +        trailing = setTimeout(() => {
    +          trailing = undefined
    +          run()
    +        }, intervalMs - elapsed)
    +      }
    +    },
    +    cancel() {
    +      clearTimeout(trailing)
    +      trailing = undefined
    +    }
    +  }
    +}
    ```
- **#38021: fix(tui): resync terminal columns on resize trailing edge (#36666)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#55931`, `#36666`

## Fix status

- Merged to main in PR #55931 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-30`.
- Subsystem: `dashboard`
