diff --git a/Agent.md b/Agent.md index 1b0acad7..4ab533c5 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (520) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (534) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 952450a9..9cdda4c7 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 520 项) +uv run pytest tests/ -v # 跑测试(当前 534 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index e98f1039..eefb54f0 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 520 items) +uv run pytest tests/ -v # run tests (currently 534 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/client/python_tui/events.py b/emrg/client/python_tui/events.py index 6584e801..2abf20cf 100644 --- a/emrg/client/python_tui/events.py +++ b/emrg/client/python_tui/events.py @@ -142,6 +142,52 @@ class ResizeEvent: "Z": (2, KeyName.TAB), # shift-tab } +# ── Legacy Windows scan codes (rant 2026-08-07T10:38:21) ────────────── +# +# Consoles without ENABLE_VIRTUAL_TERMINAL_INPUT (pre-Win10-1607, or when +# SetConsoleMode with the flag fails) deliver extended keys as a 0xE0 or +# 0x00 prefix followed by an IBM PC scan code — e.g. ↑ = 0xE0 0x48 — +# instead of ANSI ESC [ A. Without translation the UTF-8-assuming input +# chain misreads 0xE0 as a 3-byte UTF-8 lead and arrow keys go dead. + +_LEGACY_SCAN_MAP: dict[int, KeyName] = { + 0x48: KeyName.UP, + 0x50: KeyName.DOWN, + 0x4B: KeyName.LEFT, + 0x4D: KeyName.RIGHT, + 0x47: KeyName.HOME, + 0x4F: KeyName.END, + 0x49: KeyName.PAGE_UP, + 0x51: KeyName.PAGE_DOWN, + 0x52: KeyName.INSERT, + 0x53: KeyName.DELETE, +} + +# Same scan codes → equivalent ANSI CSI sequence (what handle_key expects) +_LEGACY_SCAN_TO_ANSI: dict[int, bytes] = { + 0x48: b"\x1b[A", + 0x50: b"\x1b[B", + 0x4B: b"\x1b[D", + 0x4D: b"\x1b[C", + 0x47: b"\x1b[H", + 0x4F: b"\x1b[F", + 0x49: b"\x1b[5~", + 0x51: b"\x1b[6~", + 0x52: b"\x1b[2~", + 0x53: b"\x1b[3~", +} + + +def normalize_legacy_scan_codes(data: bytes) -> bytes | None: + """Translate a 2-byte legacy Windows scan-code sequence to ANSI CSI. + + Returns the ANSI equivalent (e.g. b'\\x1b[A' for ↑) or None when the + bytes are not a recognized legacy extended-key sequence. + """ + if len(data) == 2 and data[0] in (0xE0, 0x00): + return _LEGACY_SCAN_TO_ANSI.get(data[1]) + return None + def parse_keypress(data: bytes) -> KeyEvent | None: """Parse a raw key sequence into a structured KeyEvent. @@ -179,6 +225,15 @@ def parse_keypress(data: bytes) -> KeyEvent | None: if 0x20 <= b <= 0x7E: return KeyEvent(char=chr(b), sequence=chr(b)) + # Legacy Windows scan-code pair (0xE0 0x48 / 0x00 0x50 etc.) — + # consoles without ENABLE_VIRTUAL_TERMINAL_INPUT (rant 2026-08-07T10:38:21) + if len(data) == 2 and data[0] in (0xE0, 0x00): + name = _LEGACY_SCAN_MAP.get(data[1]) + if name: + return KeyEvent( + name=name, sequence=f"0x{data[0]:02X} {data[1]:02X}" + ) + # ESC sequence if data[0] == 0x1B: seq = data[1:].decode("ascii", errors="replace") @@ -323,6 +378,22 @@ def feed(self, data: bytes) -> list[bytes]: while len(self._buf) > 0: b = self._buf[0] + # Legacy Windows scan-code prefix (no VT input): 0xE0 0x48 = ↑. + # Intercept before _utf8_len (which would misread 0xE0 as a + # 3-byte UTF-8 lead) and normalize to the ANSI equivalent. + # Only intercept when the second byte is a recognized scan code + # (0x47-0x53); valid UTF-8 continuation bytes after 0xE0 are + # always 0xA0-0xBF, so the ranges are disjoint and this is exact. + if b in (0xE0, 0x00): + if len(self._buf) >= 2: + 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 to normal + # processing. Lone 0x00 reaches the single-byte path below; + # 0xE0 + non-scan-code byte is handled as UTF-8. # Determine bytes needed for this sequence need = 1 if b == 0x1B: diff --git a/emrg/client/python_tui/win32.py b/emrg/client/python_tui/win32.py index f75c5bd1..7c2d5e12 100644 --- a/emrg/client/python_tui/win32.py +++ b/emrg/client/python_tui/win32.py @@ -10,6 +10,15 @@ cmd/conhost (Windows Terminal enables VT by default) - set the fd to binary mode so CRLF translation is off (key sequences like the arrow prefix ESC [ A arrive byte-identical to POSIX) + +ENABLE_VIRTUAL_TERMINAL_INPUT (rant 2026-08-07T10:38:21) is required: +without it conhost delivers keystrokes in the OEM code page (GBK on Chinese +systems → UTF-8-assuming input chain garbles CJK IME text) and arrow keys +arrive as legacy 0xE0 scan codes instead of ANSI ESC [ A/B/C/D. With VT +input enabled the console delivers UTF-8 + standard ANSI sequences, fixing +both problems at once. Pre-Win10-1607 consoles reject the flag — fall back +to window-input-only raw mode (InputParser defensively translates legacy +scan codes). """ from __future__ import annotations @@ -27,9 +36,14 @@ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 # output mode ENABLE_PROCESSED_OUTPUT = 0x0001 ENABLE_WINDOW_INPUT = 0x0008 - -# Raw mode = everything off except window input (keeps console resize events) -_RAW_INPUT_MODE = ENABLE_WINDOW_INPUT +ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 # input mode (rant 2026-08-07T10:38:21) + +# Raw mode = everything off except window input + VT input. +# ENABLE_VIRTUAL_TERMINAL_INPUT makes conhost deliver UTF-8 keystrokes +# (CJK IME works) and standard ANSI arrow sequences (ESC [ A/B/C/D). +# Without it: OEM code page bytes (GBK on Chinese systems) garble the +# UTF-8-assuming input chain, and arrows arrive as legacy 0xE0 scan codes. +_RAW_INPUT_MODE = ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT # Output mode that makes ANSI/VT sequences work _VT_OUTPUT_MODE = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING @@ -71,6 +85,11 @@ def enable_raw_mode(self, fd: int) -> bool: return False self._saved_modes[fd] = (in_mode, out_mode if out_mode is not None else 0) ok_in = self._set_console_mode(handle, _RAW_INPUT_MODE) + if not ok_in: + # Pre-Win10-1607 consoles reject ENABLE_VIRTUAL_TERMINAL_INPUT — + # fall back to window-input-only raw mode (InputParser in + # events.py defensively translates legacy 0xE0 scan codes). + ok_in = self._set_console_mode(handle, ENABLE_WINDOW_INPUT) ok_out = True if out_mode is not None: ok_out = self._set_console_mode(self._fd_to_handle(1), _VT_OUTPUT_MODE) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index c7529926..8f71b908 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1976,8 +1976,15 @@ async def _handle_list_sessions( async def _handle_list_projects( self, ws ) -> None: - """Read projects.yml and return all project entries.""" - evolution_cwd = str(EVOLUTION_CWD.resolve()) + """Read projects.yml and return all project entries. + + No evolution-workspace filter (rant 2026-08-07T10:48:00): projects.yml + only contains explicitly registered entries, and on packaged installs + the emrg project's only path IS ~/.emrg/evolution/emrg — filtering it + hid emrg from /rant entirely. _touch_project still skips evolution + subdirs so evolution cycles' cwd is never auto-tracked as a user + project. + """ projects: list[dict] = [] try: if self._projects_log.exists(): @@ -1989,14 +1996,6 @@ async def _handle_list_projects( "path": p.get("path", "")} for p in data if isinstance(p, dict) ] - # Filter out evolution workspace (exact match + subdirs) - projects = [ - p for p in projects - if not ( - p["path"] == evolution_cwd - or p["path"].startswith(evolution_cwd + os.sep) - ) - ] except (yaml.YAMLError, OSError): logger.exception("Failed to read projects.yml") await self._send(ws, { diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 26e2cab7..e8c015d0 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -674,3 +674,42 @@ async def fake_exec(*args, **kwargs): monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) result = asyncio.run(server._check_github_auth()) assert result == {"authenticated": True, "user": "octocat", "method": "gh"} + + +# ── /rant project list shows evolution-workspace entries (rant 10:48:00) ── + + +def test_list_projects_includes_evolution_workspace(tmp_path, monkeypatch): + """A registered project under the evolution workspace is NOT filtered from /rant. + + Discriminating-power fix (review cycle 190846): the registered path must + actually live under the monkeypatched EVOLUTION_CWD — otherwise a restored + filter would keep the entry and the test would pass anyway (false + confidence). Here the emrg entry's path is under tmp_path (= EVOLUTION_CWD), + so re-adding the old filter would exclude it and fail the assertion. + """ + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + evolution_cwd = str(tmp_path.resolve()) + emrg_path = f"{evolution_cwd}/emrg" # under EVOLUTION_CWD on purpose + projects_file = tmp_path / "projects.yml" + projects_file.write_text( + f"- name: emrg\n path: {emrg_path}\n" + "- name: other\n path: /home/u/work/other\n", + encoding="utf-8", + ) + monkeypatch.setattr(server, "_projects_log", projects_file) + monkeypatch.setattr(dmod, "EVOLUTION_CWD", tmp_path) + + writer = _FakeWriter() + asyncio.run(server._handle_list_projects(writer)) + + assert len(writer._frames) == 1 + reply = json.loads(writer._frames[0]) + assert reply["type"] == "projects_list" + paths = [p["path"] for p in reply["projects"]] + assert emrg_path in paths # emrg visible even though under evolution cwd + assert "/home/u/work/other" in paths diff --git a/tests/test_input_parser.py b/tests/test_input_parser.py index 9eedc632..8a9f318e 100644 --- a/tests/test_input_parser.py +++ b/tests/test_input_parser.py @@ -177,3 +177,90 @@ def test_unknown_escape_consumed(self): parser = InputParser() results = parser.feed(b"\x1bZ") assert results == [b"\x1bZ"] + + +# ── Legacy Windows scan codes (rant 2026-08-07T10:38:21) ── + + +class TestLegacyScanCodes: + """0xE0/0x00 prefix + scan code → normalized ANSI sequence.""" + + def test_legacy_up(self): + parser = InputParser() + assert parser.feed(b"\xe0\x48") == [b"\x1b[A"] + + def test_legacy_down_zero_prefix(self): + parser = InputParser() + assert parser.feed(b"\x00\x50") == [b"\x1b[B"] + + def test_legacy_left_right(self): + parser = InputParser() + assert parser.feed(b"\xe0\x4b") == [b"\x1b[D"] + assert parser.feed(b"\xe0\x4d") == [b"\x1b[C"] + + def test_legacy_home_end(self): + parser = InputParser() + assert parser.feed(b"\xe0\x47") == [b"\x1b[H"] + assert parser.feed(b"\xe0\x4f") == [b"\x1b[F"] + + def test_legacy_pgup_pgdn_ins_del(self): + parser = InputParser() + assert parser.feed(b"\xe0\x49") == [b"\x1b[5~"] + assert parser.feed(b"\xe0\x51") == [b"\x1b[6~"] + assert parser.feed(b"\xe0\x52") == [b"\x1b[2~"] + assert parser.feed(b"\xe0\x53") == [b"\x1b[3~"] + + def test_legacy_unknown_scan_waits_as_utf8(self): + """0xE0 + non-scan-code byte is NOT consumed as a pair — it falls + through to the UTF-8 path (pre-PR behavior). 0xE0 is the UTF-8 lead + for U+0800-U+0FFF; valid continuation bytes (0xA0-0xBF) are disjoint + from scan codes (0x47-0x53), so gating on the map is exact.""" + parser = InputParser() + assert parser.feed(b"\xe0\xff") == [] + assert parser.has_pending() + + def test_e0_led_utf8_not_garbled(self): + """Regression (review 4882245397): 0xE0-led UTF-8 scripts must + survive intact — Thai ก (U+0E01) and Devanagari अ (U+0905).""" + parser = InputParser() + thai = "ก".encode() # E0 B8 81 + assert parser.feed(thai) == [thai] + deva = "अ".encode() # E0 A4 85 + assert parser.feed(deva) == [deva] + + def test_nul_not_swallowed_with_next_key(self): + """Regression (review 4882245397): lone 0x00 (Ctrl+@) followed by a + key in the same read must yield two sequences, not one pair.""" + parser = InputParser() + assert parser.feed(b"\x00A") == [b"\x00", b"A"] + + def test_legacy_incomplete_waits(self): + """A lone 0xE0 prefix waits for the scan-code byte.""" + parser = InputParser() + assert parser.feed(b"\xe0") == [] + assert parser.has_pending() + assert parser.feed(b"\x48") == [b"\x1b[A"] + + def test_utf8_cjk_still_works(self): + """CJK UTF-8 multi-byte input is unaffected by the scan-code branch.""" + parser = InputParser() + encoded = "中".encode() + assert parser.feed(encoded) == [encoded] + + +class TestParseKeypressLegacy: + def test_parse_legacy_up(self): + from emrg.client.python_tui.events import KeyName, parse_keypress + key = parse_keypress(b"\xe0\x48") + assert key is not None + assert key.name == KeyName.UP + + def test_parse_legacy_down(self): + from emrg.client.python_tui.events import KeyName, parse_keypress + key = parse_keypress(b"\x00\x50") + assert key is not None + assert key.name == KeyName.DOWN + + def test_parse_legacy_unknown_returns_none(self): + from emrg.client.python_tui.events import parse_keypress + assert parse_keypress(b"\xe0\xff") is None