From 8ba1742ccd6a1231ac9fac61b55f666f26866982 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 6 Jun 2026 19:51:15 +0800 Subject: [PATCH 1/2] fix(cli): use get_wch() for CJK/Unicode input in curses session browser Replace stdscr.getch() with stdscr.get_wch() in _session_browse_picker and _run_curses_menu to support CJK (Korean, Chinese, Japanese) and emoji input. getch() only returns byte values (0-255), silently dropping all Unicode characters. get_wch() returns proper Unicode strings. Key comparisons updated to handle both integer codes (from getch() fallback) and string characters (from get_wch()): Enter, Esc, Backspace, Ctrl+U, and quit key. Fallback to getch() via AttributeError for curses builds without wide-char support. Fixes #40446 --- hermes_cli/curses_ui.py | 21 ++- hermes_cli/main.py | 25 ++- tests/hermes_cli/test_curses_cjk_input.py | 207 ++++++++++++++++++++++ 3 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 tests/hermes_cli/test_curses_cjk_input.py diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index acaa614b0673a..afd9706553e99 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -204,7 +204,7 @@ def _scroll_for_cursor( def _handle_active_search_key( - curses_mod, key: int, search: _SearchState + curses_mod, key: "int | str", search: _SearchState ) -> tuple[bool, bool, bool]: """Handle a key while the search prompt is active. @@ -214,7 +214,7 @@ def _handle_active_search_key( if not search.active: return False, False, False - if key == 27: + if key in {27, "\x1b"}: # Esc stops search AND clears the query, restoring the full list (so a # no-match filter can't strand the user on an empty list). Signals # `changed` when there was a query so the driver resets scroll/cursor. @@ -223,18 +223,22 @@ def _handle_active_search_key( search.query = "" return True, False, had_query - if key in (curses_mod.KEY_BACKSPACE, 127, 8): + if key in {curses_mod.KEY_BACKSPACE, 127, 8, "\x7f"}: search.query = search.query[:-1] return True, False, True - if key == 21: # Ctrl+U + if key in {21, "\x15"}: # Ctrl+U search.query = "" return True, False, True - if key in (curses_mod.KEY_ENTER, 10, 13): + if key in {curses_mod.KEY_ENTER, 10, 13, "\n", "\r"}: return True, True, False - if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255 + if isinstance(key, str) and key.isprintable(): + search.query += key + return True, False, True + + if isinstance(key, int) and 32 <= key < 127: # printable ASCII fallback for getch() search.query += chr(key) return True, False, True @@ -475,7 +479,10 @@ def _draw(stdscr): stdscr.refresh() if use_search: - key = stdscr.getch() + try: + key = stdscr.get_wch() + except AttributeError: + key = stdscr.getch() if search.active: # Active search consumes query-editing keys; nav keys diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4316bc1f53554..1740e7c0612d2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -908,7 +908,12 @@ def _curses_browse(stdscr): pass stdscr.refresh() - key = stdscr.getch() + # Use get_wch() for Unicode (CJK/emoji) support; fall back + # to getch() on platforms/curses builds that lack it. + try: + key = stdscr.get_wch() + except AttributeError: + key = stdscr.getch() if key in {curses.KEY_UP,}: if filtered: @@ -916,11 +921,11 @@ def _curses_browse(stdscr): elif key in {curses.KEY_DOWN,}: if filtered: cursor = (cursor + 1) % len(filtered) - elif key in {curses.KEY_ENTER, 10, 13}: + elif key in {curses.KEY_ENTER, 10, 13, "\n", "\r"}: if filtered: result_holder[0] = filtered[cursor]["id"] return - elif key == 27: # Esc + elif key in {27, "\x1b"}: # Esc if search_text: # First Esc clears the search search_text = "" @@ -930,7 +935,7 @@ def _curses_browse(stdscr): else: # Second Esc exits return - elif key in {curses.KEY_BACKSPACE, 127, 8}: + elif key in {curses.KEY_BACKSPACE, 127, 8, "\x7f"}: if search_text: search_text = search_text[:-1] if search_text: @@ -939,10 +944,16 @@ def _curses_browse(stdscr): filtered = list(sessions) cursor = 0 scroll_offset = 0 - elif key == ord("q") and not search_text: + elif key in {ord("q"), "q"} and not search_text: return - elif 32 <= key <= 126: - # Printable character → add to search filter + elif isinstance(key, str) and key.isprintable(): + # Printable character (including CJK/emoji) → add to filter + search_text += key + filtered = [s for s in sessions if _match(s, search_text)] + cursor = 0 + scroll_offset = 0 + elif isinstance(key, int) and 32 <= key <= 126: + # Fallback: getch() returned printable ASCII search_text += chr(key) filtered = [s for s in sessions if _match(s, search_text)] cursor = 0 diff --git a/tests/hermes_cli/test_curses_cjk_input.py b/tests/hermes_cli/test_curses_cjk_input.py new file mode 100644 index 0000000000000..d045842c7fb5b --- /dev/null +++ b/tests/hermes_cli/test_curses_cjk_input.py @@ -0,0 +1,207 @@ +"""Tests for CJK/Unicode input support in curses session browser and menu. + +The session-browser integration test uses a key-generator that never +exhausts, and patches curses internals so the rendering loop is a no-op. +""" +import curses +from unittest.mock import MagicMock, patch +import pytest + + +# --------------------------------------------------------------------------- +# _handle_active_search_key: Unicode support (unit tests — no curses needed) +# --------------------------------------------------------------------------- + +class TestHandleActiveSearchKeyCJK: + """Verify _handle_active_search_key accepts string keys from get_wch().""" + + def _make_search(self, query="", active=True): + from hermes_cli.curses_ui import _SearchState + s = _SearchState() + s.query = query + s.active = active + return s + + def test_korean_char_appended_to_query(self): + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, "한", search) + assert handled is True + assert confirm is False + assert changed is True + assert search.query == "한" + + def test_chinese_char_appended_to_query(self): + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="test") + handled, confirm, changed = _handle_active_search_key(curses, "中", search) + assert handled is True + assert search.query == "test中" + + def test_japanese_char_appended_to_query(self): + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, "あ", search) + assert handled is True + assert search.query == "あ" + + def test_emoji_appended_to_query(self): + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, "🎉", search) + assert handled is True + assert search.query == "🎉" + + def test_ascii_int_still_works(self): + """getch()-style integer keys should still work as fallback.""" + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, ord("a"), search) + assert handled is True + assert search.query == "a" + + def test_non_printable_string_ignored(self): + """Non-printable strings should be ignored.""" + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, "\x00", search) + assert handled is False + assert search.query == "" + + def test_escape_key_clears_search(self): + """Esc (int 27) should still clear the search.""" + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="한글", active=True) + handled, confirm, changed = _handle_active_search_key(curses, 27, search) + assert handled is True + assert search.query == "" + assert search.active is False + + def test_special_int_key_not_treated_as_char(self): + """Integer special keys (e.g. KEY_UP=259) should not be treated as text.""" + from hermes_cli.curses_ui import _handle_active_search_key + search = self._make_search(query="") + handled, confirm, changed = _handle_active_search_key(curses, 259, search) + assert handled is False + assert search.query == "" + + +# --------------------------------------------------------------------------- +# _session_browse_picker: key-handling integration via mock wrapper +# --------------------------------------------------------------------------- + +class TestSessionBrowsePickerCJK: + """Verify _session_browse_picker accepts CJK input via get_wch().""" + + SAMPLE_SESSIONS = [ + {"id": "s1", "title": "한글 테스트 세션", "preview": "Korean session", "source": "cli"}, + {"id": "s2", "title": "English Session", "preview": "Normal preview", "source": "cli"}, + {"id": "s3", "title": "中文会话测试", "preview": "Chinese session", "source": "gateway"}, + {"id": "s4", "title": "日本語セッション", "preview": "Japanese session", "source": "cli"}, + ] + + def _run_picker(self, sessions, key_sequence): + """Run _session_browse_picker feeding *key_sequence* via get_wch(). + + Uses a non-exhausting key generator: after the sequence is consumed, + it returns 'q' (quit) to terminate the loop cleanly. + """ + from hermes_cli.main import _session_browse_picker + + quit_sentinel = object() + seq = list(key_sequence) + + def key_gen(): + for k in seq: + yield k + while True: + yield "q" # quit sentinel after sequence + + gen = key_gen() + + mock_win = MagicMock() + mock_win.getmaxyx.return_value = (40, 120) + + mock_stdscr = MagicMock() + mock_stdscr.getmaxyx.return_value = (40, 120) + mock_stdscr.get_wch = MagicMock(side_effect=lambda: next(gen)) + mock_stdscr.getch = MagicMock(side_effect=lambda: next(gen)) + mock_stdscr.derwin = MagicMock(return_value=mock_win) + + def fake_wrapper(fn): + return fn(mock_stdscr) + + with patch.object(curses, 'wrapper', fake_wrapper), \ + patch.object(curses, 'curs_set'), \ + patch.object(curses, 'has_colors', return_value=False), \ + patch.object(curses, 'start_color'), \ + patch.object(curses, 'use_default_colors'), \ + patch.object(curses, 'init_pair'): + return _session_browse_picker(sessions) + + def test_korean_input_filters_and_selects(self): + """Typing Korean '한' then Enter should select the Korean session.""" + result = self._run_picker(self.SAMPLE_SESSIONS, ["한", "\n"]) + assert result == "s1" + + def test_chinese_input_filters_and_selects(self): + """Typing Chinese '中' then Enter should select the Chinese session.""" + result = self._run_picker(self.SAMPLE_SESSIONS, ["中", "\n"]) + assert result == "s3" + + def test_japanese_input_filters_and_selects(self): + """Typing Japanese '日' then Enter should select the Japanese session.""" + result = self._run_picker(self.SAMPLE_SESSIONS, ["日", "\n"]) + assert result == "s4" + + def test_emoji_input_filters_and_selects(self): + """Typing emoji '🎉' then Enter should match and select.""" + sessions = [ + {"id": "e1", "title": "🎉 Party Session", "preview": "fun", "source": "cli"}, + {"id": "e2", "title": "Normal", "preview": "boring", "source": "cli"}, + ] + result = self._run_picker(sessions, ["🎉", "\n"]) + assert result == "e1" + + def test_multi_char_cjk_search(self): + """Typing multiple CJK chars narrows the filter.""" + # Type '한글' (2 chars) then Enter + result = self._run_picker(self.SAMPLE_SESSIONS, ["한", "글", "\n"]) + assert result == "s1" + + def test_getch_fallback_when_get_wch_unavailable(self): + """When get_wch raises AttributeError, getch() ASCII should still work.""" + from hermes_cli.main import _session_browse_picker + + seq = [ord("E"), ord("n"), 10] # 'E', 'n', Enter + quit_seq = list(seq) + def key_gen(): + for k in quit_seq: + yield k + while True: + yield ord("q") + + gen = key_gen() + + mock_win = MagicMock() + mock_win.getmaxyx.return_value = (40, 120) + + mock_stdscr = MagicMock() + mock_stdscr.getmaxyx.return_value = (40, 120) + mock_stdscr.get_wch = MagicMock(side_effect=AttributeError) + mock_stdscr.getch = MagicMock(side_effect=lambda: next(gen)) + mock_stdscr.derwin = MagicMock(return_value=mock_win) + + def fake_wrapper(fn): + return fn(mock_stdscr) + + with patch.object(curses, 'wrapper', fake_wrapper), \ + patch.object(curses, 'curs_set'), \ + patch.object(curses, 'has_colors', return_value=False), \ + patch.object(curses, 'start_color'), \ + patch.object(curses, 'use_default_colors'), \ + patch.object(curses, 'init_pair'): + result = _session_browse_picker(self.SAMPLE_SESSIONS) + + # "En" matches "English Session" + assert result == "s2" From 66e66e5709a475984c26e5b5489c23918a6e0df7 Mon Sep 17 00:00:00 2001 From: Liuhao Date: Sun, 7 Jun 2026 13:45:51 +0800 Subject: [PATCH 2/2] test(cli): mock get_wch() in curses session browser tests The PR replaced stdscr.getch() with stdscr.get_wch() for CJK/Unicode support, but the test mock only intercepted getch(). The default MagicMock.get_wch() returned a MagicMock object instead of raising AttributeError, so the curses input loop never matched any key and hung until the 30s pytest timeout. --- tests/hermes_cli/test_session_browse.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_session_browse.py b/tests/hermes_cli/test_session_browse.py index 833729973ae55..7101ef5383b76 100644 --- a/tests/hermes_cli/test_session_browse.py +++ b/tests/hermes_cli/test_session_browse.py @@ -252,8 +252,9 @@ def _run_with_keys(self, sessions, key_sequence): mock_stdscr = MagicMock() mock_stdscr.getmaxyx.return_value = (30, 120) mock_stdscr.getch.side_effect = key_sequence + mock_stdscr.get_wch.side_effect = key_sequence - # Capture what curses.wrapper receives and call it with our mock + # Capture what curses.wrapper receives and call it with our mock stdscr with patch("curses.wrapper") as mock_wrapper: # When wrapper is called, invoke the function with our mock stdscr def run_inner(func):