Skip to content

fix: UTF-8 boundary safety, scroll-to-bottom on reattach, tmux/dtach session persistence#9

Open
shockstricken wants to merge 1 commit into
cloudcli-ai:mainfrom
shockstruck:fix/stability-and-display
Open

fix: UTF-8 boundary safety, scroll-to-bottom on reattach, tmux/dtach session persistence#9
shockstricken wants to merge 1 commit into
cloudcli-ai:mainfrom
shockstruck:fix/stability-and-display

Conversation

@shockstricken

Copy link
Copy Markdown

Summary

Three independent fixes for stability and display issues observed when running the web-terminal plugin behind a CloudCLI host. All are opt-in or backward-compatible.

1. UTF-8 byte-boundary safety in the pty pipeline (src/server.ts)

node-pty was emitting onData chunks as strings. When the underlying read crossed the middle of a multi-byte codepoint (common with emoji, box-drawing borders, CJK), the chunk was split mid-byte and forwarded as malformed UTF-8. xterm.js then rendered the well-known smeared-border / wrong-width-character glitch.

This switches node-pty to encoding: null (raw Buffer chunks) and routes them through a per-session TextDecoder with {stream: true}. Trailing incomplete bytes are buffered until the next chunk, so what we send over the WebSocket always contains only complete codepoints.

2. Scroll-to-bottom on tab show, attach, and reconnect (src/index.ts)

Reopening a tab — or reconnecting after a disconnect — left the viewport wherever the user had last scrolled, so the prompt was often off-screen and the tab appeared frozen.

show(), attachTo(), and the 'ready' (reconnected) message handler now each call terminal.scrollToBottom() after the fit, so the user always lands at the current prompt without losing scrollback history.

3. Optional tmux / dtach session persistence (src/server.ts)

New env var WEB_TERMINAL_SESSION_BACKEND:

Value Behavior
`none` (default) One pty per WebSocket, original behavior.
`tmux` Wraps shell in `tmux -L web -u new-session -A -s `. Browser refresh reattaches instead of SIGHUPing the running program.
`dtach` Wraps shell in `dtach -A -z`. Lighter than tmux; same survival semantics.

Companion vars `WEB_TERMINAL_SESSION_NAME` and `WEB_TERMINAL_DTACH_SOCKET` tune the session/socket identifier.

This lets hosts that ship `tmux` or `dtach` give users long-running `claude` / `codex` / `gemini` sessions that survive browser refresh and intermittent network drops without any client-side changes.

Test plan

  • `npm install && npm run build` succeeds (verified locally; `tsc --noEmit` and full emit both clean)
  • In a host without the env var set, behavior matches main (one pty per WS, SIGHUP on close)
  • With `WEB_TERMINAL_SESSION_BACKEND=tmux`, opening two browser tabs to the same host attaches to the same shell; refreshing one keeps the other intact
  • Renders emoji / box-drawing borders / CJK characters cleanly when the pty stream spans chunk boundaries
  • Closing and reopening a tab lands at the bottom of the buffer

Notes

  • `package.json` and `package-lock.json` intentionally unchanged.
  • No new runtime dependencies. `tmux` and `dtach` are runtime-resolved on the host PATH only when the env var is set.
  • The local `PtyProcess.onData` interface was widened to `string | Buffer` to match `node-pty`'s actual runtime behavior in raw mode.

…istence

Three independent fixes for stability and display issues users see when
working in the web terminal:

1. UTF-8 byte-boundary safety in the pty pipeline (server.ts)
   node-pty was emitting chunks as strings, which meant a multi-byte
   codepoint landing on a chunk boundary got split mid-byte and forwarded
   to the WebSocket as malformed UTF-8. xterm.js then rendered the smeared
   border / wrong-width character glitch users reported around emoji,
   box-drawing chars, and CJK text. Switching node-pty to encoding:null
   gives us raw Buffer chunks and routing them through a per-session
   TextDecoder with {stream:true} buffers any trailing incomplete bytes
   until the next chunk arrives. The string sent over the WebSocket now
   always contains only complete codepoints.

2. Scroll-to-bottom on tab focus and reconnect (index.ts)
   Reopening a tab — or reconnecting after a disconnect — left the
   viewport wherever the user had last scrolled, which often meant the
   chat / shell pane appeared frozen with the prompt off-screen. show(),
   attachTo(), and the 'ready' (reconnected) handler now each call
   terminal.scrollToBottom() after the fit, so the user always lands at
   the current prompt without losing scrollback history.

3. Optional tmux / dtach session persistence (server.ts)
   New WEB_TERMINAL_SESSION_BACKEND env var. When set to 'tmux' the
   pty wraps the shell in tmux new-session -A -s <name>; when set to
   'dtach' it wraps in dtach -A. Either way, browser refresh closes
   the WebSocket but the shell — and any foreground program like
   claude / codex / gemini — survives, and the next connect reattaches.
   Default 'none' preserves the original behavior.

   Companion env vars WEB_TERMINAL_SESSION_NAME and
   WEB_TERMINAL_DTACH_SOCKET tune the session identifier and socket path.

README documents the new env vars under a "Session persistence" section.
@Snailflyer

Copy link
Copy Markdown

The tmux/dtach persistence option is the right direction for long-running Claude/Codex/Gemini sessions, but I would test one layer above "reattach works".

Opening two tabs and refreshing one proves the process survives. The user-facing contract also needs to prove the reattached terminal is still the live write target:

  • backend (tmux/dtach) and session name
  • PTY or pane generation
  • tab/client id
  • output cursor before input
  • tiny input sent after reconnect
  • output cursor after input
  • result: reattached_live_session, visible_buffer_only, new_pty_created, wrong_session_name, or input_not_consumed

Faryo has the same invariant from the mobile/browser side: the control surface is only complete when the original live tmux-backed session advances, not when the UI reconnects or the bridge accepts input. Reference GIF: https://github.com/Snailflyer/faryo/releases/download/v1.0.7/faryo-public-redacted-same-session-handoff-walkthrough-20260528-0120.gif

CoderLuii added a commit to CoderLuii/HolyClaude that referenced this pull request Jun 18, 2026
Context:
HolyClaude #40 reported unreadable CloudCLI Web Terminal output with black squares, missing letters, and broken box drawing in the full image.

What changed:
Patched the pinned CloudCLI web-terminal plugin during the Docker build so PTY output uses raw UTF-8 byte decoding before xterm.js receives it. Expanded the default terminal font fallback stack and added a per-browser WebGL disable toggle for renderer-specific glyph failures. Added regression coverage for the patch script and documented the update path.

Verification:
node --check scripts/patch-cloudcli-web-terminal-rendering.mjs
node --test tests/patch_cloudcli_web_terminal_rendering.test.mjs
node --test tests/patch_cloudcli_disable_self_update.test.mjs
python tests/test_notify.py
npm install and npm run build against cloudcli-plugin-terminal 2bb28540ff5fda84972f99489f976551b8a552e8 after applying the patch
git diff --cached --check

Release:
Tag and GitHub release should be v1.3.5. Docker tag workflow must publish latest, slim, 1.3.5, and 1.3.5-slim before issue #40 is closed.

Constraint: Local Docker Desktop daemon was unavailable and the docker validation VM was unreachable from this session.
Rejected: Add fonts-noto-cjk by default | large image-size cost without proof that missing container fonts are the root cause.
Rejected: Vendor a fork of cloudcli-plugin-terminal | a fail-closed source patch keeps upstream provenance and is easier to drop after upstream accepts the fix.
Confidence: medium
Scope-risk: moderate
Directive: Do not remove this patch until upstream cloudcli-plugin-terminal carries equivalent UTF-8 stream decoding and terminal renderer fallback behavior.
Tested: Patch script syntax, patch idempotence, anchor-drift failure, existing self-update regression, notify tests, patched upstream plugin build.
Not-tested: Local Docker full/slim builds and browser runtime smoke because no Docker daemon or validation host was reachable.
Related: #40
Related: cloudcli-ai/cloudcli-plugin-terminal#9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants