diff --git a/Agent.md b/Agent.md index 701081df..d0508da1 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (986) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (988) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (254: 45 daemon_client + 19 conn-manager + 22 app-commands + 126 renderer smoke + 15 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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/emrg/client/app.py b/emrg/client/app.py index 154743ef..3acff439 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -28,6 +28,44 @@ logger = logging.getLogger(__name__) +def _csi_modifier_action(data: bytes) -> str | None: + """Map modifier-prefixed CSI arrow sequences to an editing action. + + macOS terminals (iTerm2/Ghostty) send \x1b[1;3D for Option+←, \x1b[1;3C + for Option+→, \x1b[1;5D for Ctrl+← etc. — the plain handler only inspects + data[2] (the '1' parameter) and drops the sequence, so the cursor never + moves while Option+Backspace (\x17) works (rant 2026-08-21T11:36:56). + Also handles the Kitty keyboard protocol form \x1b[68;3u (key code 68 = + 'D' = Left, 67 = 'C' = Right) with an Alt modifier. + + Returns 'word_left' / 'word_right' for Alt/Ctrl+←/→, else None. + """ + if len(data) < 4 or data[0] != 0x1B or data[1] != 0x5B: + return None + if b";" not in data: + return None + final = data[-1] + if not (0x40 <= final <= 0x7E): + return None + try: + parts = data[2:-1].split(b";") + if not parts or any(not p.isdigit() for p in parts): + return None + params = [int(p) for p in parts] + except ValueError: + return None + if len(params) < 2: + return None + mod = params[1] + if mod not in (3, 5, 7): # Alt(3) / Ctrl(5) / Alt+Ctrl(7) + return None + if final in (0x44, 0x43): # legacy CSI: D=← C=→ + return "word_left" if final == 0x44 else "word_right" + if final == 0x75 and params[0] in (67, 68): # Kitty CSI-u: 68='D' 67='C' + return "word_left" if params[0] == 68 else "word_right" + return None + + def _format_status_left(title: str, sid: str, model: str = "") -> str: """Format left status: version + session title + short ID + model. @@ -1476,6 +1514,16 @@ async def handle_key(data: bytes) -> bool: if b == 0x1B and len(data) >= 3: if data[1] == 0x5B: c = data[2] + # Modifier-prefixed arrows (macOS Option/Ctrl+←→): map to word + # movement (rant 2026-08-21T11:36:56). + _csi_action = _csi_modifier_action(data) + if _csi_action is not None: + if _csi_action == "word_left": + inp.move_word_left() + else: + inp.move_word_right() + term.render() + return True if c == 0x41: # Up avail = max(1, term.viewport.viewport_width - 2) if inp._cursor_vrow(avail) == 0: diff --git a/tests/test_app_widgets.py b/tests/test_app_widgets.py index 7ce5c642..2ccab592 100644 --- a/tests/test_app_widgets.py +++ b/tests/test_app_widgets.py @@ -325,3 +325,51 @@ def test_chat_history_line_cache_remove_sync(): # Removing a row not in the list is a no-op chat.remove(_CountingWidget("ghost")) assert len(chat.rows) == 2 + + +# ── Modifier-prefixed CSI arrows (rant 2026-08-21T11:36:56) ───────────────── + + +def test_csi_alt_arrow_mapping(): + """Option/Ctrl+←/→ map to word movement; unmodified/other keys don't.""" + from emrg.client.app import _csi_modifier_action + + assert _csi_modifier_action(b"\x1b[1;3D") == "word_left" # Option+← + assert _csi_modifier_action(b"\x1b[1;3C") == "word_right" # Option+→ + assert _csi_modifier_action(b"\x1b[1;5D") == "word_left" # Ctrl+← + assert _csi_modifier_action(b"\x1b[1;5C") == "word_right" # Ctrl+→ + assert _csi_modifier_action(b"\x1b[1;7D") == "word_left" # Alt+Ctrl+← + # Kitty keyboard protocol: key code 68='D' (Left) / 67='C' (Right) + assert _csi_modifier_action(b"\x1b[68;3u") == "word_left" + assert _csi_modifier_action(b"\x1b[67;3u") == "word_right" + # No-op sequences + assert _csi_modifier_action(b"\x1b[D") is None # bare ← (no modifier) + assert _csi_modifier_action(b"\x1b[1;2D") is None # Shift+← (mod 2) + assert _csi_modifier_action(b"\x1b[3~") is None # Delete key + assert _csi_modifier_action(b"\x1b[1;3A") is None # Alt+↑ (not mapped) + assert _csi_modifier_action(b"abc") is None # not a CSI sequence + + +def test_input_widget_move_word(): + """move_word_left/right jump across whitespace-delimited words.""" + from emrg.client.widgets import InputWidget + + w = InputWidget() + w.text = "foo bar baz" + w.cursor = len(w.text) + w.move_word_left() + assert w.cursor == 8 # start of 'baz' + w.move_word_left() + assert w.cursor == 4 # start of 'bar' + w.move_word_left() + assert w.cursor == 0 # start of 'foo' + w.move_word_left() + assert w.cursor == 0 # already at start — no-op + w.move_word_right() + assert w.cursor == 4 + w.move_word_right() + assert w.cursor == 8 + w.move_word_right() + assert w.cursor == 11 # end + w.move_word_right() + assert w.cursor == 11 # already at end — no-op