fix(ui): slash command input now echoed as user message in chat (closes #840)

* fix(ui): echo slash command input as user message in chat (#840)

Slash commands like /skills, /help, /status previously showed only the
assistant response with no user message above it — the conversation
appeared to start from nowhere.

Fix: executeCommand() now returns {noEcho:bool} instead of true/false
(returns null when no command matched). send() in messages.js pushes a
user message bubble before returning when noEcho is false.

Commands with noEcho:true are action-only and don't get echoed:
/clear, /new, /stop, /retry, /undo, /voice, /model, /workspace,
/theme, /usage, /reasoning.

Commands without noEcho (get echoed):
/help, /skills, /status, /title, /compress, /compact, /personality.

16 new tests in test_issue840_slash_echo.py.

* fix(ui): push user message BEFORE running slash handler (ordering bug)

The PR as originally written pushed the user message AFTER the slash
command handler ran.  That works correctly for async handlers (the
assistant response lands later, after the user push) but breaks for
sync handlers like cmdHelp which push their assistant response
synchronously:

  S.messages = [assistant response, user "/help"]   ← reverse order

The chat would render the help content ABOVE the user's own "/help"
input — not what the issue asked for.

Fix: look up the command inline, push the user message first (for
echo-worthy commands), then run the handler.  If the handler opts out
(returns false — e.g. /reasoning <level>), pop the user message back
off so the normal send path can add it cleanly when forwarding to the
agent.

Renamed the flow so it's clear we're not calling executeCommand twice
(my first attempt did that by accident).  executeCommand() stays as a
public API returning null or {noEcho:bool} — just isn't the only path
send() uses now.

Added 2 regression tests:

- test_send_pushes_user_message_before_running_handler: asserts
  the user push appears before the handler invocation in source order.
- test_send_rolls_back_user_push_on_handler_optout: asserts the
  S.messages.pop() for the opt-out case.

Also tightened the existing `test_send_checks_noecho_flag` and
`test_send_pushes_user_message_for_echo_commands` tests to look at
the new `_cmd.noEcho` pattern inline (vs the original
`cmdResult.noEcho`).  Removed `test_send_uses_null_check_not_truthy`
(obsoleted — the control flow no longer stores the executeCommand
return in a variable).

Full suite: 1767 passed, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): compress/compact noEcho + title/personality confirmation messages

Applied Opus mentor review fixes:
- compress and compact: add noEcho:true (S.messages reset internally causes
  user bubble to flicker/disappear without noEcho)
- /title <name>: push assistant confirmation message after rename succeeds
- /personality <name>: push assistant confirmation message after set succeeds
- 4 new regression tests covering the above invariants

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-21 23:08:24 -07:00
committed by GitHub
parent 9b628c27ab
commit db57c47ff3
4 changed files with 264 additions and 19 deletions

View File

@@ -1,5 +1,15 @@
# Hermes Web UI -- Changelog
## [v0.50.146] — 2026-04-22
### Fixed
- **Slash command input now shown as user message in chat** — commands like `/help`,
`/skills`, `/status` previously produced a response with no visible user input above
it, making the conversation appear to start from nowhere. Added a `noEcho` flag to
action-only commands (`/clear`, `/new`, `/stop`, etc.) and echo the user's input as
a message bubble for commands that produce a chat response. User message is pushed
BEFORE the handler runs to ensure correct ordering in `S.messages`. Closes #840. (#841)
## [v0.50.145] — 2026-04-22
### Fixed

View File

@@ -3,24 +3,26 @@
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
// noEcho:true = action-only commands that don't produce a chat response.
// Commands without noEcho get a user message echoed to the chat (#840).
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]'},
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models'},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
{name:'new', desc:t('cmd_new'), fn:cmdNew},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh']},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
];
const SLASH_SUBARG_SOURCES={
@@ -38,13 +40,15 @@ function parseCommand(text){
function executeCommand(text){
const parsed=parseCommand(text);
if(!parsed)return false;
if(!parsed)return null;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return false;
if(!cmd)return null;
// A handler may return `false` to opt out of interception — e.g. /reasoning
// with an effort level falls through so the agent's own handler sees it,
// preserving the pre-existing pass-through behaviour for that subcommand.
return cmd.fn(parsed.args) !== false;
if(cmd.fn(parsed.args)===false)return null;
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
return {noEcho:!!cmd.noEcho};
}
function getMatchingCommands(prefix){
@@ -513,6 +517,8 @@ async function cmdPersonality(args){
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
renderMessages();
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
@@ -537,6 +543,8 @@ async function cmdTitle(args){
if(typeof syncTopbar==='function')syncTopbar();
if(typeof renderSessionList==='function')renderSessionList();
showToast(`${t('title_set')} "${S.session.title}"`);
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
renderMessages();
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdRetry(){

View File

@@ -16,9 +16,34 @@ async function send(){
}
return;
}
// Slash command intercept -- local commands handled without agent round-trip
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
$('msg').value='';autoResize();hideCmdDropdown();return;
// Slash command intercept -- local commands handled without agent round-trip.
// We push the user message BEFORE running the handler for echo-worthy
// commands so chat order is correct: some handlers (e.g. cmdHelp) push
// their assistant response synchronously. If we pushed AFTER, S.messages
// would be [assistant, user] and the chat would show the response above
// the user's own input — reverse chronological order (#840 ordering bug).
if(text.startsWith('/')&&!S.pendingFiles.length){
const _parsedCmd=parseCommand(text);
const _cmd=_parsedCmd?COMMANDS.find(c=>c.name===_parsedCmd.name):null;
if(_cmd){
let _pushedUser=false;
if(!_cmd.noEcho){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
_pushedUser=true;
renderMessages();
}
// Run the handler directly (we already looked it up). If it returns
// false it's opting out — e.g. /reasoning <level> falls through so the
// agent sees the raw text. Roll back the echo push in that case so
// the normal send path doesn't duplicate it.
if(_cmd.fn(_parsedCmd.args)===false){
if(_pushedUser){S.messages.pop();renderMessages();}
// Fall through to normal send path
} else {
$('msg').value='';autoResize();hideCmdDropdown();return;
}
}
}
if(!S.session){await newSession();await renderSessionList();}

View File

@@ -0,0 +1,202 @@
"""Tests for slash command echo (#840) — user message shown in chat after /skills, /help, etc."""
import os
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestExecuteCommandReturnValue:
"""executeCommand() now returns null or {noEcho:bool} instead of true/false."""
def test_execute_command_returns_null_on_no_match(self):
src = _read("static/commands.js")
idx = src.find("function executeCommand(")
block = src[idx:idx + 400]
# Must return null (not false) when no command matched
assert "return null;" in block, (
"executeCommand must return null when no command found (not false)"
)
def test_execute_command_returns_noecho_object(self):
src = _read("static/commands.js")
assert "return {noEcho:" in src, (
"executeCommand must return {noEcho:...} when a command runs"
)
def test_no_echo_flag_on_clear(self):
src = _read("static/commands.js")
# Find the clear command entry
idx = src.find("name:'clear'")
assert idx >= 0
entry = src[idx:idx + 100]
assert "noEcho:true" in entry, "/clear must have noEcho:true"
def test_no_echo_flag_on_new(self):
src = _read("static/commands.js")
idx = src.find("name:'new'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/new must have noEcho:true"
def test_no_echo_flag_on_stop(self):
src = _read("static/commands.js")
idx = src.find("name:'stop'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/stop must have noEcho:true"
def test_no_echo_flag_on_retry(self):
src = _read("static/commands.js")
idx = src.find("name:'retry'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/retry must have noEcho:true"
def test_no_echo_flag_on_undo(self):
src = _read("static/commands.js")
idx = src.find("name:'undo'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/undo must have noEcho:true"
def test_no_echo_flag_on_voice(self):
src = _read("static/commands.js")
idx = src.find("name:'voice'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/voice must have noEcho:true"
def test_no_echo_flag_on_theme(self):
src = _read("static/commands.js")
idx = src.find("name:'theme'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho:true" in entry, "/theme must have noEcho:true"
def test_no_echo_flag_on_model(self):
src = _read("static/commands.js")
idx = src.find("name:'model'")
assert idx >= 0
entry = src[idx:idx + 130]
assert "noEcho:true" in entry, "/model must have noEcho:true"
def test_skills_has_no_noecho(self):
"""Commands that produce chat responses must NOT have noEcho."""
src = _read("static/commands.js")
idx = src.find("name:'skills'")
assert idx >= 0
entry = src[idx:idx + 100]
assert "noEcho" not in entry, "/skills must echo — no noEcho flag"
def test_help_has_no_noecho(self):
src = _read("static/commands.js")
idx = src.find("name:'help'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho" not in entry, "/help must echo — no noEcho flag"
def test_status_has_no_noecho(self):
src = _read("static/commands.js")
idx = src.find("name:'status'")
assert idx >= 0
entry = src[idx:idx + 80]
assert "noEcho" not in entry, "/status must echo — no noEcho flag"
class TestSendSlashIntercept:
"""send() in messages.js must push user message for echo-worthy commands."""
def test_send_checks_noecho_flag(self):
src = _read("static/messages.js")
idx = src.find("Slash command intercept")
block = src[idx:idx + 1400]
assert "_cmd.noEcho" in block or "cmd.noEcho" in block, (
"send() must check the command's noEcho flag before pushing user message (#840)"
)
def test_send_pushes_user_message_for_echo_commands(self):
src = _read("static/messages.js")
idx = src.find("Slash command intercept")
block = src[idx:idx + 1400]
assert "role:'user'" in block and "content:text" in block, (
"send() must push {role:'user', content:text} for echo-worthy slash commands (#840)"
)
def test_send_pushes_user_message_before_running_handler(self):
"""Ordering fix: cmdHelp-style handlers push their assistant response
synchronously. The user message must be pushed BEFORE the handler
runs so S.messages ends up [user, assistant] — not [assistant, user]
which would display in reverse chronological order."""
src = _read("static/messages.js")
idx = src.find("Slash command intercept")
block = src[idx:idx + 1400]
user_push_pos = block.find("role:'user'")
handler_call_pos = block.find("_cmd.fn(")
if handler_call_pos == -1:
handler_call_pos = block.find("cmd.fn(")
assert user_push_pos != -1, "user message push not found in intercept block"
assert handler_call_pos != -1, "handler invocation not found in intercept block"
assert user_push_pos < handler_call_pos, (
"User message must be pushed BEFORE the handler runs — otherwise "
"sync handlers like cmdHelp push the assistant response first and "
"the chat displays in reverse chronological order."
)
def test_send_rolls_back_user_push_on_handler_optout(self):
"""If a handler returns false (opt-out — e.g. /reasoning <level>),
the pre-pushed user message must be popped so the normal send path
can add it cleanly for forwarding to the agent."""
src = _read("static/messages.js")
idx = src.find("Slash command intercept")
block = src[idx:idx + 1400]
assert "S.messages.pop()" in block, (
"send() must S.messages.pop() the user message on handler opt-out "
"to avoid duplicating the user turn when falling through to "
"the normal send path."
)
assert "===false" in block or "=== false" in block, (
"opt-out must be detected by handler returning === false"
)
def test_compress_has_no_echo_flag():
"""compress is action-only — it resets S.messages internally; echo would flicker."""
src = _read("static/commands.js")
import re
m = re.search(r"\{name:'compress'[^}]+\}", src)
assert m, "compress entry not found in COMMANDS"
assert 'noEcho:true' in m.group(), f"compress missing noEcho:true: {m.group()}"
def test_compact_has_no_echo_flag():
"""compact is an alias for compress — same noEcho requirement."""
src = _read("static/commands.js")
import re
m = re.search(r"\{name:'compact'[^}]+\}", src)
assert m, "compact entry not found in COMMANDS"
assert 'noEcho:true' in m.group(), f"compact missing noEcho:true: {m.group()}"
def test_title_with_args_pushes_confirmation_message():
"""When /title <name> succeeds, cmdTitle pushes an assistant confirmation bubble."""
src = _read("static/commands.js")
# After the rename API call succeeds, an assistant message is pushed
idx = src.find("title_set")
segment = src[idx: idx + 300]
assert 'S.messages.push' in segment, "cmdTitle success path must push an assistant message"
assert "role:'assistant'" in segment, "cmdTitle confirmation must have role:assistant"
def test_personality_with_args_pushes_confirmation_message():
"""When /personality <name> succeeds, cmdPersonality pushes an assistant confirmation bubble."""
src = _read("static/commands.js")
# Find the set-personality success path (after the clear/none/default branch)
# S.messages.push comes BEFORE the personality_set toast
idx = src.find("personality_set')+`**${name}**`")
assert idx != -1, "cmdPersonality confirmation push not found in source"
segment = src[max(0, idx-100): idx + 200]
assert 'S.messages.push' in segment, "cmdPersonality success path must push an assistant message"
assert "role:'assistant'" in segment, "cmdPersonality confirmation must have role:assistant"