Skip to content

terminal_guard() missing scroll region reset and screen content cleanup #681

@Trecek

Description

@Trecek

Problem

Terminal corruption persists after exiting autoskillit cook or autoskillit order sessions, despite the VT100 reset expansion in PR #678 (commit 2233836e). The prior fix correctly addressed input garbling (Ctrl-C printing 9;5u, bracketed paste markers appearing in pasted text) but did not address the visual corruption that is the primary user-facing symptom: Claude TUI text left on screen, shell prompt rendering in the middle of stale output, and screen content not being cleaned up on exit.

What the user sees

After exiting a cook/order session, the terminal shows:

  • Claude's TUI output (tables, status text, "Resume this session with..." messages) persisting as static text
  • The shell prompt appearing overlaid on top of or in the middle of Claude's output
  • No screen cleanup — the user must manually run clear or reset to get a usable terminal

What the prior fix (PR #678) addressed

PR #678 expanded _BASE_RESET from 5 to 13 VT100 escape sequences and added a conditional _KITTY_RESET. This correctly resets DEC private modes (bracketed paste, mouse tracking, focus events, synchronized output, application cursor keys, Kitty keyboard protocol) and restores kernel TTY discipline via termios.tcsetattr(). Layer 1 (kernel TTY) and Layer 2 (DEC private modes) are fully addressed.

What the prior fix did NOT address

Terminal state has four layers. The prior fix covers two. The remaining two are the root cause of the visual corruption:

Layer Controls Fixed By Status
L1: Kernel TTY discipline echo, canonical mode, signals termios.tcsetattr() Fixed
L2: DEC private modes bracketed paste, mouse, cursor keys VT100 escape sequences Fixed
L3: Scroll region / character sets screen subdivision, line-drawing chars \033[r, \033(B Missing
L4: Screen content literal text in terminal buffer \033[H\033[J or alternate screen Missing

Root Cause

1. Missing scroll region reset (\033[r)

Claude Code's Ink TUI may set a custom scroll region (DECSTBM) for its layout (e.g., status bar, input area). When Claude exits — especially on Ctrl-C or abnormal exit — the scroll region is not reset. terminal_guard() includes \033[!p (DECSTR soft reset) which per the VT220 spec should reset DECSTBM, but DECSTR's scroll region behavior is inconsistently implemented across terminal emulators:

  • Windows Terminal: Partial DECSTR implementation
  • tmux/screen: DECSTR behavior varies by version
  • xterm: Full support only when decTerminalID >= 200

An explicit \033[r (DECSTBM with no parameters = reset scroll region to full screen) is missing from _BASE_RESET. This is a universally safe no-op on terminals without a custom scroll region set. Without it, the shell inherits a constrained scroll region and renders its prompt inside a sub-area of the screen — explaining why the prompt appears in the middle of Claude's output.

Confirmed: grep for \033[r, DECSTBM, scroll.region, and \x1b[r across the entire codebase returns zero matches.

2. No screen content cleanup (\033[H\033[J)

Claude Code renders its entire TUI to the main terminal buffer (not the alternate screen buffer). When it exits, all TUI content persists as literal text. No escape sequence in _BASE_RESET clears this content — the guard only restores terminal modes, not terminal content.

The comment in _terminal.py at line 37 documents this as intentional: "DECSTR soft reset (18 DEC attributes, no screen clear)". But the omission of screen clearing is exactly what causes the visual corruption.

The community workaround documented in claude-code#39272 uses:

claude() { command claude "$@"; printf '\e[?2004l\e[?1l\e[?25h\e[J'; }

Note the \e[J at the end — this erases from cursor to end of screen, which is the screen content cleanup terminal_guard() is missing.

3. Missing character set reset (\033(B) — secondary

_BASE_RESET does not include \033(B (designate G0 character set as US ASCII). If Ink enters a line-drawing character set (\033(0) for box-drawing and exits without resetting, subsequent output renders as line-drawing characters. DECSTR should handle this, but per the implementation variance above, an explicit reset is safer.

Current Code

src/autoskillit/cli/_terminal.py lines 26-40:

_BASE_RESET = (
    "\033[?1049l"  # exit alternate screen buffer (defensive)
    "\033[?2004l"  # disable bracketed paste mode
    "\033[?1000l"  # disable normal mouse tracking
    "\033[?1002l"  # disable button-event mouse tracking
    "\033[?1003l"  # disable any-event mouse tracking
    "\033[?1006l"  # disable SGR extended mouse protocol
    "\033[?1004l"  # disable focus in/out events
    "\033[?2026l"  # disable synchronized output
    "\033[?1l"     # disable application cursor keys (DECCKM)
    "\033>"        # numeric keypad mode (DECKPNM)
    "\033[!p"      # DECSTR soft reset (18 DEC attributes, no screen clear)
    "\033[0m"      # reset SGR attributes
    "\033[?25h"    # show cursor (DECTCEM)
)

Proposed Fix

Add three escape sequences to _BASE_RESET:

  1. \033[r — Reset scroll region to full screen (DECSTBM no-params). Place before DECSTR. Universally safe.
  2. \033(B — Reset G0 character set to US ASCII. Place after DECSTR. Universally safe.
  3. \033[H\033[J — Move cursor to home position + erase from cursor to end of screen. Place as the last sequences after cursor show. Clears stale TUI content without destroying scrollback history.

Updated _BASE_RESET:

_BASE_RESET = (
    "\033[?1049l"  # exit alternate screen buffer (defensive)
    "\033[?2004l"  # disable bracketed paste mode
    "\033[?1000l"  # disable normal mouse tracking
    "\033[?1002l"  # disable button-event mouse tracking
    "\033[?1003l"  # disable any-event mouse tracking
    "\033[?1006l"  # disable SGR extended mouse protocol
    "\033[?1004l"  # disable focus in/out events
    "\033[?2026l"  # disable synchronized output
    "\033[?1l"     # disable application cursor keys (DECCKM)
    "\033>"        # numeric keypad mode (DECKPNM)
    "\033[r"       # reset scroll region to full screen (DECSTBM)
    "\033[!p"      # DECSTR soft reset (18 DEC attributes, no screen clear)
    "\033(B"       # reset G0 character set to US ASCII
    "\033[0m"      # reset SGR attributes
    "\033[?25h"    # show cursor (DECTCEM)
    "\033[H"       # move cursor to home position
    "\033[J"       # erase from cursor to end of screen
)

Test Changes Required

Tests in tests/cli/test_terminal.py that assert on the content of _BASE_RESET output will need updating:

  • Add assertions for \033[r (scroll region reset)
  • Add assertions for \033(B (character set reset)
  • Add assertions for \033[H and \033[J (screen clear)
  • The test test_emits_full_base_reset_on_normal_exit and similar should verify these new sequences are present

Prior Fix History

For context, _terminal.py has been modified across 8 commits over 14 days:

Commit Date Change Layer Addressed
7fb9af55 03-25 Created terminal_guard() with 5-sequence reset L1, L2 (partial)
e433e7f7 03-25 Broadened exception handling on tcgetattr L1 (hardening)
64eb3a9d 03-26 Added non-TTY guard, return type, debug log L1 (hardening)
6cf4a534 03-26 Added alt-screen entry ?1049h L4 (attempt — caused regression)
67776ddd 03-26 Reverted alt-screen entry, codified exit-only contract Revert of above
a16a6287 03-26 Merge of initial work into PR #511 L1, L2 (partial)
ff231ee3 04-08 Expanded to 13-sequence _BASE_RESET + _KITTY_RESET L2 (complete)
2233836e 04-08 Merge into PR #678 L2 (complete)

Each fix incrementally improved Layer 1 and Layer 2 coverage. Layer 3 (scroll region) and Layer 4 (screen content) have never been addressed. The one attempt at Layer 4 (alt-screen entry in 6cf4a534) caused a scrollbar regression because DECSET 1049 is a boolean toggle with no nesting counter — emitting it before Claude's own entry overwrote Ink's DECSC cursor save point.

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementationstagedImplementation staged and waiting for promotion to main

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions