From 3d1e14d6742b78f6d2c2837c18a734bff906d6a6 Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 13:50:44 +0800 Subject: [PATCH 1/7] fix(cli): add /sessions handler for classic REPL mode Fixes #22951 The /sessions slash command was registered in CommandDef registry (commit 09a491464) but only had a handler in TUI mode. This adds the missing handler for classic CLI mode. - Add elif canonical == "sessions" branch in process_command() - Add _handle_sessions_command() that opens SessionDB, lists sessions, and calls _session_browse_picker() for interactive selection - Handle edge cases: no sessions, DB errors, user cancellation - Use lazy imports to avoid circular dependencies --- cli.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cli.py b/cli.py index d474e405a16e9..ea4da8bc07d03 100644 --- a/cli.py +++ b/cli.py @@ -5597,6 +5597,34 @@ def _handle_resume_command(self, cmd_original: str) -> None: else: _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + def _handle_sessions_command(self) -> None: + """Handle /sessions — list and browse saved sessions.""" + # Fix for Issue #22951: /sessions unknown command in classic REPL mode + if not self._session_db: + from hermes_state import format_session_db_unavailable + _cprint(f" {format_session_db_unavailable()}") + return + + try: + sessions = self._session_db.list_sessions() + except Exception as exc: + _cprint(f" Error reading sessions: {exc}") + return + + if not sessions: + _cprint(" No saved sessions yet. Sessions are created automatically as you chat.") + return + + try: + from hermes_cli.main import _session_browse_picker + selected = _session_browse_picker(sessions) + except Exception as exc: + _cprint(f" Error browsing sessions: {exc}") + return + + if selected is not None: + self._relaunch(session_id=selected) + def _handle_branch_command(self, cmd_original: str) -> None: """Handle /branch [name] — fork the current session into a new independent copy. @@ -6922,6 +6950,8 @@ def process_command(self, command: str) -> bool: self.new_session(title=title) elif canonical == "resume": self._handle_resume_command(cmd_original) + elif canonical == "sessions": + self._handle_sessions_command() elif canonical == "model": self._handle_model_switch(cmd_original) elif canonical == "gquota": From 37f273fd61eb724d81b8391e99dc03951ac38f6f Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 14:02:32 +0800 Subject: [PATCH 2/7] fix(cli): recover oneshot response from agent internal state when streaming returns empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22975 hermes -z exits with code 0 but produces zero bytes on stdout. The agent successfully calls the API and receives a valid response, but agent.chat() returns empty string when streaming consumes the response before capture. - After agent.chat(), check agent._last_full_response for the accumulated response - Fallback: reconstruct from conversation_history assistant messages - Zero breaking changes — additive recovery logic only - Fixes empty stdout across all providers (anthropic, mlx, grok, gemini) --- hermes_cli/oneshot.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 5ef53c9fff036..3a25e81e34f19 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -333,7 +333,23 @@ def _run_agent( agent.stream_delta_callback = None agent.tool_gen_callback = None - return agent.chat(prompt) or "" + response = agent.chat(prompt) or "" + + # Fix for Issue #22975: oneshot returns empty stdout despite successful API response. + # When streaming is active, agent.chat() may return an empty string because the + # streaming path consumes the response content before it can be captured as a + # return value. We recover the response from the agent's internal state. + if not response and hasattr(agent, "_last_full_response"): + response = agent._last_full_response or "" + + # Fallback: try to reconstruct from conversation history if still empty + if not response and hasattr(agent, "conversation_history") and agent.conversation_history: + for msg in reversed(agent.conversation_history): + if msg.get("role") == "assistant" and msg.get("content"): + response = msg["content"] + break + + return response def _oneshot_clarify_callback(question: str, choices=None) -> str: From 78b71eb293faa1bd75ff0de1a42384051396f5c4 Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 14:04:52 +0800 Subject: [PATCH 3/7] fix(tui): clear terminal on resize to prevent ghost separator lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22976 When working with multiple Hermes TUI sessions side by side, users resize terminal windows frequently. Each resize event caused blank separator lines (─) to accumulate because Ink's VDOM diff does not clear previous terminal output when canvas geometry changes. - Add terminal clear escape sequence (\x1b[2J\x1b[H) before re-rendering on resize - This forces a full screen invalidation, preventing ghost line accumulation - No impact on normal operation — only fires during resize events --- ui-tui/src/app/useMainApp.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 648cc1b69a00e..bc962fc225552 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -423,6 +423,12 @@ export function useMainApp(gw: GatewayClient) { clearTimeout(timer) timer = setTimeout(() => { timer = undefined + // Fix for Issue #22976: clear the terminal before re-rendering on resize + // to prevent ghost separator lines from accumulating. Ink's VDOM diff + // does not invalidate old output when the canvas geometry changes. + if (stdout && typeof stdout.write === 'function') { + stdout.write('\x1b[2J\x1b[H') + } void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) }, 100) } From d9b7b3ea68d775df5655c3347867ca9793981198 Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 14:05:47 +0800 Subject: [PATCH 4/7] fix(cli): guard run_in_terminal against background thread RuntimeWarning Fixes #22970 Running /new, /clear, /undo, or /reload-mcp mid-turn produced a RuntimeWarning: 'coroutine run_in_terminal..run was never awaited' The warning occurred because _prompt_text_input() called run_in_terminal() from process_loop which runs in a background thread. run_in_terminal() returns a coroutine that can't be scheduled on the main event loop when called from a non-main thread. - Add threading.current_thread() is threading.main_thread() guard before calling run_in_terminal(), matching the pattern in _run_curses_picker() - Fall back to direct _ask() call when on a background thread - Fixes RuntimeWarning for all destructive slash commands: /new, /clear, /undo, /reload-mcp --- cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index ea4da8bc07d03..dd08a2c95765a 100644 --- a/cli.py +++ b/cli.py @@ -5897,11 +5897,18 @@ def _ask(): if self._app: from prompt_toolkit.application import run_in_terminal + import threading was_visible = self._status_bar_visible self._status_bar_visible = False self._app.invalidate() try: - run_in_terminal(_ask) + # Fix for Issue #22970: RuntimeWarning when slash commands run + # from background threads. run_in_terminal() returns a coroutine + # that can't be scheduled when not on the main thread. + if threading.current_thread() is threading.main_thread(): + run_in_terminal(_ask) + else: + _ask() finally: self._status_bar_visible = was_visible self._app.invalidate() From c5458dcdeb9f7c0aa6fed159b7743568a36daccd Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 14:28:07 +0800 Subject: [PATCH 5/7] fix(dashboard): display vision_analyze tool results with correct tool role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22961 In the Dashboard session view, vision_analyze tool results were displayed with the sender label as 'user' instead of 'tool'. This happened because the message role was incorrectly set to 'user' for some vision tool results. - Add effectiveRole override in MessageBubble: when a message has tool_name but role is 'user', display it as 'tool' role instead - This ensures vision_analyze results show the correct 'tool' label and styling (warning/amber color instead of primary) - Only affects display — does not modify the underlying session data --- web/src/pages/SessionsPage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index dd2ad6b23148f..2e8449e3abe9f 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -166,7 +166,13 @@ function MessageBubble({ }, }; - const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system; + // Fix for Issue #22961: vision_analyze tool results displayed as user messages. + // When a message has tool_name but role is incorrectly set to 'user' (happens + // for some vision tool results), override the display role to 'tool'. + const effectiveRole = + msg.tool_name && msg.role === "user" ? "tool" : msg.role; + + const style = ROLE_STYLES[effectiveRole] ?? ROLE_STYLES.system; const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label; From 4f6fe81a5b1cca07bb103097c1264dd8474a33a3 Mon Sep 17 00:00:00 2001 From: KhanCold Date: Sun, 10 May 2026 14:31:06 +0800 Subject: [PATCH 6/7] fix(cli): add /indicator handler for non-TUI CLI mode Fixes #22960 The /indicator slash command was registered in COMMAND_REGISTRY but had no handler in process_command(), making it silently fail in non-TUI CLI mode. - Add _handle_indicator_command() method to set busy-indicator style (kaomoji/emoji/unicode/ascii) via save_config_value() - Add elif canonical == "indicator" dispatch branch in process_command() - Supports status query (/indicator, /indicator status) and style setting (/indicator kaomoji|emoji|unicode|ascii) - Mirrors the TUI gateway's config.set handler in tui_gateway/server.py --- cli.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/cli.py b/cli.py index dd08a2c95765a..e11cb342fe407 100644 --- a/cli.py +++ b/cli.py @@ -6961,6 +6961,8 @@ def process_command(self, command: str) -> bool: self._handle_sessions_command() elif canonical == "model": self._handle_model_switch(cmd_original) + elif canonical == "indicator": + self._handle_indicator_command(cmd_original) elif canonical == "gquota": self._handle_gquota_command(cmd_original) @@ -7934,6 +7936,48 @@ def _toggle_yolo(self): " — all commands auto-approved. Use with caution." ) + def _handle_indicator_command(self, cmd_original: str) -> None: + """Pick the TUI busy-indicator style (kaomoji/emoji/unicode/ascii). + + Usage: + /indicator → show current style + /indicator kaomoji → set to kaomoji (default) + /indicator emoji → set to emoji + /indicator unicode → set to unicode (braille spinner) + /indicator ascii → set to ascii + """ + from hermes_cli.config import load_config + from hermes_cli.colors import Colors as _Colors + + VALID_STYLES = {"kaomoji", "emoji", "unicode", "ascii"} + + # Parse arg + arg = "" + try: + parts = (cmd_original or "").strip().split(None, 1) + if len(parts) > 1: + arg = parts[1].strip().lower() + except Exception: + arg = "" + + cfg = load_config() or {} + current = ((cfg.get("display") or {}).get("tui_status_indicator", "kaomoji")) + + if arg in ("status", "?", ""): + _cprint(f" {_Colors.BOLD}Busy indicator:{_Colors.RESET} {current}") + _cprint(f" Available: {', '.join(sorted(VALID_STYLES))}") + return + + if arg not in VALID_STYLES: + _cprint(f" Unknown indicator style: {arg}") + _cprint(f" Available: {', '.join(sorted(VALID_STYLES))}") + return + + if save_config_value("display.tui_status_indicator", arg): + _cprint(f" Busy indicator set to: {_Colors.GREEN}{arg}{_Colors.RESET} (saved)") + else: + _cprint(f" Busy indicator set to: {arg}") + def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. From d6b41edb0bde3152743fdc2132d0a1706463b61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E5=90=AF=E6=98=8E=EF=BC=88QimingShi=EF=BC=89?= Date: Sun, 10 May 2026 14:34:51 +0800 Subject: [PATCH 7/7] fix(tui): remove chmod from npm build script to fix Windows build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #22952 The npm build script in ui-tui/package.json ended with '&& chmod +x dist/entry.js', which fails on Windows because chmod is not a valid command. This caused 'hermes --tui' to fail with 'TUI build failed' even though all compilation steps (esbuild, tsc, babel) succeeded. - Remove '&& chmod +x dist/entry.js' from the build script - The execute bit is a cosmetic POSIX convenience — the TUI launcher always invokes it via 'node dist/entry.js', which works regardless of file permissions - Fixes Windows TUI startup failure --- ui-tui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/package.json b/ui-tui/package.json index 2bb1616a0a29c..6b1619f529e28 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js", + "build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile", "build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension", "type-check": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/",