Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
71 changes: 71 additions & 0 deletions emrg/client/python_tui/events.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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")
Expand DownExpand Up@@ -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:
Expand Down
25 changes: 22 additions & 3 deletions emrg/client/python_tui/win32.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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

Expand DownExpand Up@@ -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)
Expand Down
19 changes: 9 additions & 10 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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():
Expand All@@ -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, {
Expand Down
39 changes: 39 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
87 changes: 87 additions & 0 deletions tests/test_input_parser.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading