Skip to content

emrg: fix Windows TUI CJK input + legacy arrow keys + /rant project visibility - #546

Merged
argszero merged 3 commits into
masterfrom
feature/win-tui-input-rant-projects
Aug 7, 2026
Merged

emrg: fix Windows TUI CJK input + legacy arrow keys + /rant project visibility#546
argszero merged 3 commits into
masterfrom
feature/win-tui-input-rant-projects

Conversation

@argszero

@argszero argszero commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Two host-reported fixes (rants verbatim below)

Rant 1 (2026-08-07T10:38:21 UTC): Windows TUI 无法输入中文 + / 命令菜单上下键失效

Root cause (host-analyzed): win32.py raw mode only set ENABLE_WINDOW_INPUT (0x0008), missing ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200). Without it conhost delivers OEM code-page bytes (GBK on Chinese systems → UTF-8-assuming input chain garbles CJK IME) and arrow keys as legacy 0xE0 scan codes that the ANSI-only parser + UTF-8-length logic misread.

Fixes:

  1. win32.py (main fix): _RAW_INPUT_MODE = ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT — console now delivers UTF-8 keystrokes + standard ANSI arrows. SetConsoleMode failure falls back to window-input-only mode (pre-Win10-1607).
  2. events.py (defensive fallback): normalize_legacy_scan_codes() translates 0xE0/0x00 + scan-code pairs (↑=0x48, ↓=0x50, ←=0x4B, →=0x4D, Home/End/PgUp/PgDn/Ins/Del) to their ANSI CSI equivalents; InputParser.feed() intercepts the prefix before _utf8_len can misread 0xE0 as a 3-byte UTF-8 lead; parse_keypress() maps them to KeyEvent names directly.

Rant 2 (2026-08-07T10:48:00 UTC): /rant 项目列表过滤 evolution 工作区导致 emrg 项目消失

Root cause (host-analyzed): _handle_list_projects filtered out any path under ~/.emrg/evolution/ — correct when evolution workspace was internal noise, obsolete since #489/#490/#535 self-heal made ~/.emrg/evolution/emrg the emrg project's only path on packaged installs.

Fix: removed the filter entirely — projects.yml only contains explicitly registered entries, so no unregistered-noise case exists. _touch_project still skips evolution subdirs (evolution cycles' cwd never auto-tracked as a user project).

Tests (+12 → 532, docs synced per #511 guard)

  • InputParser: legacy ↑↓←→/Home/End/PgUp/PgDn/Ins/Del normalization, unknown-scan passthrough (never UTF-8-garbled), lone-prefix wait, CJK UTF-8 unaffected
  • parse_keypress: legacy up/down → KeyName, unknown → None
  • daemon: registered evolution-workspace project appears in projects_list

Acceptance coverage

  • Rant 1: Windows cmd + Windows Terminal — CJK input + /-menu arrows (needs host verification on Windows; POSIX regression-free via 532 tests)
  • Rant 2: projects.yml emrg entry → visible in /rant; dev machine unchanged; _touch_project behavior preserved

…scan-code fallback) and show evolution-workspace projects in /rant

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Needs fix: InputParser 0xE0 intercept garbles valid UTF-8 (U+0800-U+0FFF scripts) — POSIX regression

Repro (run against 3f51bb0):

InputParser().feed(bytes([0xE0, 0xB8, 0x81]))   # Thai 'ก' (U+0E01)
→ [b'\xe0\xb8', b'\x81']  (should be [b'\xe0\xb8\x81'])

InputParser().feed(bytes([0xE0, 0xA4, 0x85]))   # Devanagari 'अ' (U+0905)
→ [b'\xe0\xa4', b'\x85']  (should be [b'\xe0\xa4\x85'])

InputParser().feed(b'\x00A')                    # Ctrl+@ then 'A' (POSIX)
→ [b'\x00A']  (should be [b'\x00', b'A'])

Root cause: the InputParser intercept in feed() treats 0xE0 as a legacy scan-code prefix unconditionally. But 0xE0 is also the UTF-8 lead byte for U+0800-U+0FFF (Devanagari, Bengali, Thai, Tibetan, etc.). _utf8_len(0xE0) correctly waited 3 bytes before this PR; the intercept now consumes 2 bytes prematurely. Same for 0x00: lone NUL (Ctrl+@) followed by any key in the same read gets swallowed.

parse_keypress is already safe (it only returns when the scan code is in the map), so only the InputParser branch needs the gate.

Suggested fix:

if b in (0xE0, 0x00):
    if len(self._buf) < 2:
        break  # wait for the scan-code byte
    ansi = normalize_legacy_scan_codes(bytes(self._buf[:2]))
    if ansi is not None:
        del self._buf[:2]
        results.append(ansi)
        continue
    # Not a recognized scan code — fall through. Valid UTF-8 after 0xE0
    # is always 0xA0-0xBF, disjoint from scan codes 0x47-0x53, so this is
    # exact. Lone 0x00 also falls through to the single-byte path.

Also update test_legacy_unknown_scan_passthrough — its current assertion (feed(b'\xe0\xff') == [b'\xe0\xff']) encodes the buggy behavior; the correct behavior for an invalid 0xE0 pair is to wait for the 3-byte UTF-8 sequence (pre-PR behavior).

Lesson referenced: #455 — verification logic must be validated in both positive AND negative states. The added tests covered recognized scan codes (positive) but not the 0xE0-led UTF-8 case (negative).

…UTF-8 (Thai/Devanagari) must not be consumed as scan pairs

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle 20260807-190422 (reviewing fix at 723c1f7; angle: regression-resolution verification)

The prior ❌ (4882245397, against 3f51bb0) flagged the InputParser 0xE0 intercept garbling U+0800-U+0FFF UTF-8. This review verifies the fix at the new head resolves it without new regressions.

Fix correctness (723c1f7): the intercept now only consumes the 2-byte pair when normalize_legacy_scan_codes returns a recognized ANSI sequence; otherwise it falls through to normal processing. The disjointness argument holds: scan codes are 0x47-0x53, valid UTF-8 continuation bytes after a 0xE0 lead are 0xA0-0xBF — the ranges never overlap, so gating on the map is exact (no false intercept, no missed scan code).

Verified on 723c1f7 (all probes pass):

  • Negative (the regression cases): Thai ก (E0 B8 81) and Devanagari अ (E0 A4 85) now survive intact; Ctrl+@ (0x00) + 'A' yields two sequences; invalid pair 0xE0 0xFF correctly waits as UTF-8 (has_pending).
  • Positive: ↑/↓/Del legacy scan codes still normalize to ESC[A / ESC[B / ESC[3~.
  • Split-read: lone 0xE0 then 0x48 → ESC[A; lone 0xE0 then UTF-8 continuation (B8 81) → intact 3-byte char. Both arrival orders handled.
  • CJK 中 unaffected; parse_keypress legacy mapping intact.
  • Full suite: 534 passed; CI green (31172467970).

Scope check: 723c1f7 touches only events.py intercept + test_input_parser.py + doc counts — no unrelated changes. The win32.py VT-input flag and daemon.py filter removal from 3f51bb0 are unchanged and were already verified correct.

Vote: 1/3 for head 723c1f7 (the 3f51bb0 ❌ is resolved by this fix; count restarts on the new head).

… path under EVOLUTION_CWD so a restored filter would fail it

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle 20260807-191724 (first review of head 387a6d4; angle: regression surface)

The 723c1f7→387a6d4 delta is test-only (tests/test_daemon.py +13/−4), so this review audits the full final state against master for regression surface.

1. events.py (+71) — legacy scan-code path

  • Intercept is gated on normalize_legacy_scan_codes returning a recognized ANSI sequence; unrecognized pairs fall through. Disjointness holds (scan codes 0x47–0x53 vs UTF-8 continuation bytes 0xA0–0xBF after a 0xE0 lead) — no false intercept, no missed scan code.
  • Split-read safe: lone 0xE0 waits; a later scan code normalizes, a later UTF-8 continuation completes the 3-byte char (both orders verified in prior cycle 190422).
  • parse_keypress legacy branch only returns on a map hit — unknown pairs return None, no crash.

2. win32.py (+25) — VT input flag

  • _RAW_INPUT_MODE now includes ENABLE_VIRTUAL_TERMINAL_INPUT; SetConsoleMode failure falls back to window-input-only (pre-Win10-1607). Output mode and binary-mode logic untouched. No change to the restore path (disable_raw_mode uses the saved pre-change mode).

3. daemon.py (+19/−16) — filter removal

  • _handle_list_projects returns all registered projects.yml entries; no filter. _touch_project still skips evolution subdirs, so evolution cycles' cwd is never auto-tracked. The removed filter was the only behavioral change; error handling (yaml/OSError) preserved.

4. Tests (+126) — discriminating power confirmed

  • The evolution-workspace test now registers the emrg path UNDER the monkeypatched EVOLUTION_CWD, so restoring the old filter would exclude it and fail the assertion (verified by simulation: old-filter KEEP=False → excluded → test fails).
  • Input tests pin both the fix (Thai/Devanagari/NUL survive) and the feature (scan codes normalize); all fail on the respective regressions.

5. Docs — counts synced to 534 (README/README.cn/Agent.md) per the #511 guard.

Verification on 387a6d4: 534 passed locally; CI green (31173305692); MERGEABLE + CLEAN; 0 conflict markers.

Vote: 1/3 for head 387a6d4 (count restarted after the test-discriminating fix).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle 20260807-192222 (2nd review of head 387a6d4; angle: Windows behavior reasoning + end-to-end data-flow trace)

Traced the full Windows input chain to confirm the fix actually reaches the parser:

1. Raw-byte delivery path (the critical question): On Windows, app.py uses a daemon-thread stdin reader (_win_stdin_loop) that does blocking os.read(stdin_fd, 4096) and pushes raw chunks into stdin_queue via call_soon_threadsafe — the main loop then feeds them to InputParser.feed(). So legacy scan-code pairs (0xE0 0x48) arrive as raw bytes exactly as the parser intercept expects. No intermediate decode/translation layer strips them. ✓

2. Both console modes converge: With VT input enabled (the main fix) conhost delivers UTF-8 + ANSI arrows directly; in the fallback (pre-Win10-1607, SetConsoleMode rejects the flag) the parser's gated intercept normalizes 0xE0/0x00 scan codes to the same ANSI sequences. Both paths produce identical ESC [ A/B/C/D bytes into handle_key. ✓

3. Downstream consumers get working arrows: _handle_selector_nav and the /-menu autocomplete both match ANSI ESC [ A/B (data[0]==0x1B, data[1]==0x5B) — which is precisely what the normalized scan codes emit. So legacy arrows now drive session/model/project selectors and the / command menu on Windows. ✓

4. No false-flush of a pending 0xE0: the 50ms lone-ESC flush timer only fires when parser._buf == bytearray(b'\x1b'); a pending 0xE0 prefix does NOT match (verified live: condition False, has_pending True), so a half-arrived scan code is never discarded as a stray Escape. ✓

5. projects_list consumer check: the only reader of projects_list is app.py:602 (the /rant project picker) — removing the filter surfaces the evolution-workspace emrg entry there with no other consumer affected. ✓

Verification on 387a6d4: CI green (31173305692); MERGEABLE + CLEAN.

Vote: 2/3 for head 387a6d4.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle 20260807-192803 (3rd review of head 387a6d4; angle: test discriminating-power re-check on final state)

Independently re-verified every new test's discriminating power by simulating each regression against the final code:

Regression simulations (all caught):

  • R1 (revert the gate → unconditional 2-byte consume): Thai ก (E0 B8 81) garbles to [e0b8, 81] and Ctrl+@+'A' collapses to [0041] → test_e0_led_utf8_not_garbled + test_nul_not_swallowed_with_next_key both fail ✓
  • R2 (remove the intercept entirely): ↑ (E0 48) hits _utf8_len=3 and waits (never yields ESC[A); ↓ (00 50) yields [00][50] not ESC[B] → test_legacy_up + test_legacy_down_zero_prefix fail ✓
  • R3 (restore the old daemon filter): the test fixture's emrg entry now sits under EVOLUTION_CWD, so the restored filter excludes it → test_list_projects_includes_evolution_workspace fails ✓ (the 190846 false-confidence defect is confirmed fixed)

Positive sanity on repo code (387a6d4): ↑/↓ normalize to ESC[A/B; Thai survives intact; Ctrl+@ yields two sequences; parse_keypress maps legacy ↑ to KeyName.UP. 534 passed; CI green (31173305692).

Process note: my first verification run accidentally imported the installed pre-fix package (~/.emrg/install/source) instead of the repo — the missing normalize_legacy_scan_codes import surfaced it immediately. Re-ran against repo code (cwd on sys.path) to confirm. Recorded as a lesson: always assert the imported module path when validating branch code.

Vote: 3/3 for head 387a6d4 — merge gate satisfied (3 consecutive ✅ from different cycles on this head: 191724 regression-surface, 192222 Windows data-flow, 192803 discriminating-power; the earlier ❌ predates this head and was resolved by 723c1f7).

@argszero
argszero merged commit 1da1933 into master Aug 7, 2026
1 check passed
@argszero
argszero deleted the feature/win-tui-input-rant-projects branch August 7, 2026 11:35
argszero added a commit that referenced this pull request Aug 7, 2026
…ndows TUI input & /rant visibility entries (#547)

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
argszero added a commit that referenced this pull request Aug 7, 2026
…p tolerance (#552)

Version bump 0.2.10 → 0.2.11 across all 6 version sources
(pyproject.toml / emrg/__init__.py / gui/package.json / uv.lock /
make-installer.sh / build-runtime.sh). Release for Windows verification:

- #541 LLM gzip body tolerance
- #543 GUI message display fixes (#544 quick-ref)
- #545 Windows GCM silent-fail Stage 1
- #546 Windows TUI CJK input + legacy arrow keys + /rant visibility
- #548/#549/#550 GitHub auth in GUI (PAT + device flow + banner, Stage 2)
- #551 quick-ref

All 548 tests green.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
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.

1 participant