HERMES-GIELIE/static/commands.js

2171 lines
92 KiB
JavaScript
Raw Normal View History

// ── Slash commands ──────────────────────────────────────────────────────────
// Built-in commands intercepted before send(). Each command runs locally
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
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>
2026-04-22 06:08:24 +00:00
// 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},
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>
2026-04-22 06:08:24 +00:00
{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:'terminal', desc:t('cmd_terminal'), fn:cmdTerminal, noEcho:true},
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>
2026-04-22 06:08:24 +00:00
{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:'use', desc:t('cmd_use'), fn:cmdUse, arg:'skill-name', subArgs:'skills', noEcho:true},
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>
2026-04-22 06:08:24 +00:00
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
2026-05-08 02:32:06 +00:00
{name:'goal', desc:t('cmd_goal'), fn:cmdGoal, arg:'[status|pause|resume|clear|text]', subArgs:['status','pause','resume','clear']},
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
{name:'interrupt', desc:t('cmd_interrupt'), fn:cmdInterrupt, arg:'message', noEcho:true},
{name:'steer', desc:t('cmd_steer'), fn:cmdSteer, arg:'message', noEcho:true},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
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>
2026-04-22 06:08:24 +00:00
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
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>
2026-04-22 06:08:24 +00:00
{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},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
{name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true},
];
const SLASH_SUBARG_SOURCES={
model:{desc:t('cmd_model'), subArgs:'models'},
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
};
function parseCommand(text){
if(!text.startsWith('/'))return null;
const parts=text.slice(1).split(/\s+/);
const name=parts[0].toLowerCase();
const args=parts.slice(1).join(' ').trim();
return {name,args};
}
const DESKTOP_COMPANION_EXTENSION_ID='desktop-companion';
const DESKTOP_COMPANION_NAME='Desktop Companion';
const DESKTOP_COMPANION_INSTALL_PATH='Settings -> Extensions -> Gallery -> Desktop Companion';
const DESKTOP_COMPANION_SETUP_GUIDE_URL='https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install';
const DESKTOP_COMPANION_LOCAL_APP_LABEL='Desktop Companion app';
function _getDesktopCompanionStatusGlobal(){
if(typeof window==='undefined') return null;
return window.__HERMES_WEBUI_DESKTOP_COMPANION_STATUS__||null;
}
function _getDesktopCompanionExtensionStatus(status){
return Array.isArray(status&&status.extensions)
? status.extensions.find(ext=>String(ext&&ext.id||'')===DESKTOP_COMPANION_EXTENSION_ID)||null
: null;
}
function _petSlashCommandArgs(rawCommandText){
const parsed=parseCommand(String(rawCommandText||'').trim());
return parsed&&parsed.name==='pet' ? parsed.args : String(rawCommandText||'').trim().replace(/^\/pet\b\s*/i,'').trim();
}
function _desktopCompanionMissingMessage(){
return `${DESKTOP_COMPANION_NAME} is not installed yet.
Install it from ${DESKTOP_COMPANION_INSTALL_PATH}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}.
The guide also shows how to start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`;
}
function _desktopCompanionDisabledMessage(){
return `${DESKTOP_COMPANION_NAME} is installed but disabled.
Enable it in Settings -> Extensions, then reload WebUI if you just changed that and start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.
Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`;
}
function _desktopCompanionReloadMessage(){
return `${DESKTOP_COMPANION_NAME} is enabled, but the adapter status is not loaded yet.
Reload WebUI if you just enabled it, then start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`;
}
function _desktopCompanionConnectMessage(){
return `${DESKTOP_COMPANION_NAME} is enabled, but the local app is not connected yet.
Start or connect the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then retry /pet.
Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`;
}
2026-06-29 00:24:29 +00:00
function _desktopCompanionStatusUnavailableMessage(){
return `${DESKTOP_COMPANION_NAME} status is unavailable right now.
Reload WebUI or check your connection, then retry /pet.`;
}
function _desktopCompanionUnavailableMessage(){
return `${DESKTOP_COMPANION_NAME} is installed and connected, but /pet is not available yet in this Desktop Companion version.
Update the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}.`;
}
2026-06-29 00:24:29 +00:00
function _desktopCompanionHookErrorMessage(){
return `${DESKTOP_COMPANION_NAME} is installed and connected, but it hit an error while handling /pet.
Check the browser console and the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then retry /pet.`;
}
async function handlePetSlashCommand(rawCommandText,meta){
const command=String(rawCommandText||'');
const commandName=String((meta&&meta.name)||'pet').trim()||'pet';
const args=_petSlashCommandArgs(command);
2026-06-29 00:24:29 +00:00
let status;
try{
status=await api('/api/extensions/status');
}catch(_e){
2026-06-29 00:24:29 +00:00
return {handled:false,message:_desktopCompanionStatusUnavailableMessage()};
}
const companion=_getDesktopCompanionExtensionStatus(status);
if(!companion){
return {handled:false,message:_desktopCompanionMissingMessage()};
}
if(companion.effective_enabled!==true){
return {handled:false,message:_desktopCompanionDisabledMessage()};
}
const companionStatus=_getDesktopCompanionStatusGlobal();
if(!companionStatus){
return {handled:false,message:_desktopCompanionReloadMessage()};
}
if(companionStatus.connected!==true){
return {handled:false,message:_desktopCompanionConnectMessage()};
}
const hook=typeof window!=='undefined'&&window.__hermesHandlePetSlashCommand;
if(typeof hook==='function'){
try{
const result=await hook({
command,
args,
source:'webui-slash-command',
metadata:{name:commandName},
});
if(result){
2026-06-29 00:24:29 +00:00
return {
handled:true,
message:result&&typeof result==='object'&&'message' in result
? String(result.message??'')
2026-06-29 00:24:29 +00:00
: '',
};
}
}catch(_e){
if(typeof console!=='undefined'&&console.error){
console.error('[hermes] Desktop Companion /pet hook error:',_e);
}
2026-06-29 00:24:29 +00:00
return {handled:false,message:_desktopCompanionHookErrorMessage()};
}
}
return {handled:false,message:_desktopCompanionUnavailableMessage()};
}
function executeCommand(text){
const parsed=parseCommand(text);
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>
2026-04-22 06:08:24 +00:00
if(!parsed)return null;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
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>
2026-04-22 06:08:24 +00:00
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.
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>
2026-04-22 06:08:24 +00:00
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){
const q=prefix.toLowerCase();
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
const seen=new Set(matches.map(c=>c.name));
const reserved=_getReservedSlashCommandSlugs();
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
if(!name.startsWith(q)||seen.has(name))continue;
matches.push({
name,
desc:spec.desc,
arg:'name',
source:'subarg-command',
});
seen.add(name);
}
if('pet'.startsWith(q)&&!seen.has('pet')){
const petMeta=Array.isArray(_agentCommandCache)
? _agentCommandCache.find(cmd=>String(cmd&&cmd.name||'').toLowerCase()==='pet')
: null;
matches.push({
name:'pet',
desc:String((petMeta&&petMeta.description)||'Desktop Companion command').trim()||'Desktop Companion command',
source:'agent',
});
seen.add('pet');
}
// Include agent/plugin commands from /api/commands metadata
for(const cmd of (_agentCommandCache||[])){
const name=String(cmd&&cmd.name||'').toLowerCase();
if(!name.startsWith(q)||seen.has(name))continue;
if(cmd.cli_only&&name!=='pet')continue;
matches.push({
name,
desc:String(cmd&&cmd.description||'').trim()||'Agent command',
source:cmd.category==='Plugin'?'plugin':'agent',
});
seen.add(name);
}
if(_agentCommandCacheReady){
for(const bundle of _bundleCommandCache){
if(!bundle.name.startsWith(q)||seen.has(bundle.name)||reserved.has(bundle.name))continue;
matches.push(bundle);
seen.add(bundle.name);
}
}
for(const skill of _skillCommandCache){
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
matches.push(skill);
seen.add(skill.name);
}
return matches;
}
let _forcedSkillDirectivePending=null;
let _slashModelCache=null;
let _slashModelCachePromise=null;
let _slashPersonalityCache=null;
let _slashPersonalityCachePromise=null;
let _bundleCommandCache=[];
let _bundleCommandLoadPromise=null;
let _bundleCommandCacheReady=false;
let _slashSkillCache=null;
let _slashSkillCachePromise=null;
let _agentCommandCache=null;
let _agentCommandCachePromise=null;
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor (Discord, May 03 2026). #1538 — Nous Portal picker showed only 4 hardcoded models ========================================================= The Settings → Default Model picker, the composer model dropdown, the /model slash command, and the Settings → Providers card all showed only four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded entries and `_build_available_models_uncached()` fell through to the generic `pid in _PROVIDER_MODELS` branch. The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5, Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun entries, and more. Fix: - New `_format_nous_label()` helper in `api/config.py` — reuses the `_format_ollama_label()` token rules, drops the vendor namespace, and appends ` (via Nous)` so labels disambiguate from same-named direct- provider entries (e.g. "Claude Opus 4.7" via direct Anthropic). - New `elif pid == "nous":` branch in `_build_available_models_uncached()` mirroring the Ollama Cloud pattern: live-fetch through `hermes_cli.models.provider_model_ids("nous")`, prefix every id with `@nous:` (matches the existing routing convention from PR-era #854 and pinned in tests/test_nous_portal_routing.py), fall back to the curated 4-entry static list when hermes_cli is unavailable. - Same fix applied to `api/providers.py:get_providers()` — that's the separate code path that builds Settings → Providers card models, and it had the identical bug shape. #1539 — Removed provider lingered in dropdowns until restart ============================================================ After Settings → Providers → Remove, the provider's models still appeared in every model dropdown until the page was reloaded. The server-side TTL cache was correctly flushed (`set_provider_key()` calls `invalidate_models_cache()` on both add and remove) but JS-side caches were never dropped: - `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds the `/model` slash-command suggestions. - `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) — populated by `populateModelDropdown()` on app boot and profile switch. Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()` which refreshed the providers card list but never asked any consumer to re-fetch /api/models. Fix: - `static/commands.js`: new `_invalidateSlashModelCache()` helper that nulls both cache slots, exposed on `window` (typeof-guarded so the module remains importable in headless vm contexts — needed by the existing tests/test_cli_only_slash_commands.py harness). - `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()` helper that calls the invalidator + `populateModelDropdown()`, wrapped in try/catch so the providers panel update never breaks if a downstream module hasn't loaded yet. Both `_saveProviderKey` and `_removeProviderKey` invoke it (defense-in-depth: same staleness shape applies to the add path too). Tests ----- - `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch surfaces ≥20 entries, every id starts with `@nous:`, every label ends with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6, Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback works when hermes_cli raises, label formatter unit tests (vendor namespace stripping, variant rendering, MiniMax mixed-case), the curated static list and its routing invariants are preserved. - `tests/test_issue1539_provider_removal_dropdown_invalidation.py` (11 tests): invalidator helper exists and clears both cache slots, exposed on window with typeof guard, both save and remove paths invoke the dropdown flush, helper calls both invalidator and populateModelDropdown, helper is resilient to missing modules, helper does not block panel refresh, server-side `set_provider_key → invalidate_models_cache` invariant pinned. Verified live on port 8789: `/api/models` Nous group returns 30 models (was 4); browser `document.getElementById('modelSelect')` exposes 30 options under the "Nous Portal" group; the dropdown-flush helper is callable from the browser and round-trip rebuild keeps the dropdown at 30 options. Test counts: - Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures (was 3990 → 4013, +23 from this PR). - QA harness pytest: 20 passed. - Browser API sanity: 11/11 passed. - Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
// Invalidate the /api/models slash-suggestion cache. Called by panels.js
// after a provider is added or removed so the next /model autocomplete
// rebuilds from a fresh /api/models response (#1539). Returning a function
// rather than letting callers poke the module-local lets/promises directly
// keeps the cache shape encapsulated to this module.
function _invalidateSlashModelCache(){
_slashModelCache=null;
_slashModelCachePromise=null;
}
// Expose on window when available. Guarded by typeof so the module is
// importable in headless test contexts (vm.runInContext) that don't
// define a window global — see tests/test_cli_only_slash_commands.py.
if(typeof window!=='undefined'){
window._invalidateSlashModelCache=_invalidateSlashModelCache;
window.invalidateSlashSkillCaches=invalidateSlashSkillCaches;
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor (Discord, May 03 2026). #1538 — Nous Portal picker showed only 4 hardcoded models ========================================================= The Settings → Default Model picker, the composer model dropdown, the /model slash command, and the Settings → Providers card all showed only four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded entries and `_build_available_models_uncached()` fell through to the generic `pid in _PROVIDER_MODELS` branch. The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5, Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun entries, and more. Fix: - New `_format_nous_label()` helper in `api/config.py` — reuses the `_format_ollama_label()` token rules, drops the vendor namespace, and appends ` (via Nous)` so labels disambiguate from same-named direct- provider entries (e.g. "Claude Opus 4.7" via direct Anthropic). - New `elif pid == "nous":` branch in `_build_available_models_uncached()` mirroring the Ollama Cloud pattern: live-fetch through `hermes_cli.models.provider_model_ids("nous")`, prefix every id with `@nous:` (matches the existing routing convention from PR-era #854 and pinned in tests/test_nous_portal_routing.py), fall back to the curated 4-entry static list when hermes_cli is unavailable. - Same fix applied to `api/providers.py:get_providers()` — that's the separate code path that builds Settings → Providers card models, and it had the identical bug shape. #1539 — Removed provider lingered in dropdowns until restart ============================================================ After Settings → Providers → Remove, the provider's models still appeared in every model dropdown until the page was reloaded. The server-side TTL cache was correctly flushed (`set_provider_key()` calls `invalidate_models_cache()` on both add and remove) but JS-side caches were never dropped: - `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds the `/model` slash-command suggestions. - `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) — populated by `populateModelDropdown()` on app boot and profile switch. Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()` which refreshed the providers card list but never asked any consumer to re-fetch /api/models. Fix: - `static/commands.js`: new `_invalidateSlashModelCache()` helper that nulls both cache slots, exposed on `window` (typeof-guarded so the module remains importable in headless vm contexts — needed by the existing tests/test_cli_only_slash_commands.py harness). - `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()` helper that calls the invalidator + `populateModelDropdown()`, wrapped in try/catch so the providers panel update never breaks if a downstream module hasn't loaded yet. Both `_saveProviderKey` and `_removeProviderKey` invoke it (defense-in-depth: same staleness shape applies to the add path too). Tests ----- - `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch surfaces ≥20 entries, every id starts with `@nous:`, every label ends with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6, Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback works when hermes_cli raises, label formatter unit tests (vendor namespace stripping, variant rendering, MiniMax mixed-case), the curated static list and its routing invariants are preserved. - `tests/test_issue1539_provider_removal_dropdown_invalidation.py` (11 tests): invalidator helper exists and clears both cache slots, exposed on window with typeof guard, both save and remove paths invoke the dropdown flush, helper calls both invalidator and populateModelDropdown, helper is resilient to missing modules, helper does not block panel refresh, server-side `set_provider_key → invalidate_models_cache` invariant pinned. Verified live on port 8789: `/api/models` Nous group returns 30 models (was 4); browser `document.getElementById('modelSelect')` exposes 30 options under the "Nous Portal" group; the dropdown-flush helper is callable from the browser and round-trip rebuild keeps the dropdown at 30 options. Test counts: - Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures (was 3990 → 4013, +23 from this PR). - QA harness pytest: 20 passed. - Browser API sanity: 11/11 passed. - Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
}
function _normalizeSlashSubArg(value){
return String(value||'').trim();
}
function _getSlashModelSubArgsFromDom(){
const sel=$('modelSelect');
if(!sel) return [];
const values=[];
for(const opt of Array.from(sel.options||[])){
const value=_normalizeSlashSubArg(opt.value||opt.textContent||'');
if(value) values.push(value);
}
return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
}
async function _loadSlashModelSubArgs(force=false){
const domValues=_getSlashModelSubArgsFromDom();
if(domValues.length&&!force){
_slashModelCache=domValues;
return domValues;
}
if(_slashModelCache&&!force) return _slashModelCache;
if(_slashModelCachePromise&&!force) return _slashModelCachePromise;
_slashModelCachePromise=(async()=>{
try{
const data=await api('/api/models');
const values=[];
for(const group of (data&&data.groups)||[]){
for(const model of (group&&group.models)||[]){
const id=_normalizeSlashSubArg(model&&model.id);
if(id) values.push(id);
}
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567) Two related dropdown bugs in one PR — same root shape (model-picker endpoints disagreeing about which Nous Portal models exist) plus the preemptive UX guard against the picker becoming unusable on large-tier Nous accounts. #1567 — Endpoint disagreement ============================= Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card showing "Nous Portal — 396 models · OAuth" while the in-conversation picker dropdown listed only the four hardcoded curated entries. Two structural causes: 1. ``api/providers.py:get_providers`` iterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog. 2. ``api/config.py:_build_available_models_uncached`` only iterates providers in ``detected_providers``, gated on ``hermes_cli.models.list_available_providers().authenticated``. That flag can disagree with ``get_auth_status(<id>).logged_in`` on some hermes_cli versions. When the disagreement happens for Nous, the picker silently falls through to the curated 4-entry static list while the providers card keeps showing the live catalog — exactly the asymmetry users report. Plus: the Nous live-fetch branch in `_build_available_models_uncached` fell back to the same curated 4-entry list when `provider_model_ids` returned an empty list (transient failure / OAuth refresh in flight), which doubles down on the disagreement instead of healing it. UX cap (the design concern Nathan flagged on triage) ==================================================== Even with the disagreement fixed, dumping a 397-model catalog into a flat dropdown is unusable. We trim the visible picker to a curated ~15-entry featured set when the catalog exceeds 25 models, and surface the rest under a new ``extra_models`` field so: - ``/model`` slash autocomplete (commands.js) covers the full catalog - ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model selected from outside the featured slice still gets a proper label - The optgroup label gets ``" (15 of 397)"`` appended so the user understands the dropdown is intentionally trimmed, not broken - The providers card surfaces ``models_total`` separately so the header still reads "397 models · OAuth" - A small "+N more" disclosure pill appears at the end of the rendered pill list (only fires for non-OAuth providers — OAuth cards never render pills) with a tooltip pointing at the slash command Featured selection rules ------------------------ Deterministic; same algorithm runs in both `/api/models` and `/api/models/live` so background enrichment doesn't undo the trim: 1. Always include the user's currently-selected model (sticky — no orphan IDs in the dropdown after a refresh) 2. Always include every entry from the curated static ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id 3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin (one model per vendor each pass) so no vendor monopolises the slots Changes by file =============== api/config.py - New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`, `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple, `_build_nous_featured_set()` helper (~80 LOC) - `_build_available_models_uncached` Nous branch: - Apply featured-set cap with sticky-selection signal - Return `extra_models` alongside `models` for the catalog tail - Decorate optgroup label with truncation count - Drop stale-4 fallback when authenticated but live-fetch empty (omit the group entirely; truth lives in the providers card and the next cache rebuild will heal it) - Keep stale-4 fallback when hermes_cli is unavailable (test envs, package mismatches) — that's a different failure mode - Detection symmetry: explicit `get_auth_status("nous").logged_in` check after the existing `list_available_providers()` loop, so the picker matches the providers card on hermes_cli versions where the two signals disagree api/providers.py:get_providers - Apply same featured-set cap so card body doesn't render 397 pills - Add `models_total` field reporting full catalog size (used by frontend for the "N models · OAuth" header text) api/routes.py:_handle_live_models - Apply same featured-set cap for `/api/models/live` so background enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim - Use sticky-selection from `cfg["model"]["model"]` matching the main endpoint's logic static/ui.js:populateModelDropdown - Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection outside the visible dropdown still renders with its proper label static/commands.js:_loadSlashModelSubArgs - Iterate `group.extra_models` so `/model` autocomplete covers the full catalog (not just the trimmed featured slice) static/panels.js:_buildProviderCard - Header count uses `p.models_total` (full catalog size) instead of `p.models.length` (trimmed slice) - Render trailing "+N more" disclosure pill when `models.length < models_total` with a tooltip pointing at the slash command static/style.css - New `.provider-card-model-tag-more` rule (italic, dashed border, cursor:help, no select) — visually distinct from real model pills Tests ===== `tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests): - TestBuildNousFeaturedSet (8): unit tests on the helper — small-catalog no-op, large-catalog cap to target, disjoint+complete invariants, priority-vendor round-robin guarantee, sticky selection with and without `@nous:` prefix, curated-flagship preservation, empty-catalog handling, determinism - TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end on a synthetic 397-model catalog vs a 20-model catalog - TestNousDetectionSymmetry (2): picker includes Nous when `get_auth_status` agrees but `list_available_providers` disagrees; picker omits Nous when both disagree - TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group; hermes_cli unavailable still falls back to static-4 - TestProvidersCardPickerSymmetry (1): both endpoints agree on exactly the same featured-set IDs + total catalog count - TestFrontendExtrasContract (4): static-source assertions pinning the JS contract for `extra_models`, `models_total`, and the "+N more" disclosure Verified live on port 8789 (30-model catalog): - /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models, 15 extra_models - /api/models/live?provider=nous: 15 entries (matches main path) - /api/providers Nous card: models_total=30, models=15 - Browser dropdown after backfill: 15 options, 30 entries in _dynamicModelLabels - Sticky selection: Claude Opus 4.7 (the active model) in the featured slice as expected 4073 pytest passed (was 4053 → 4073, +20 from this PR). 3 CI test runs (3.11/3.12/3.13) green. QA harness 11/11 passed. Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT) Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
// Include extra_models (the catalog tail that doesn't render as
// <option> entries when the picker is capped) so /model autocomplete
// covers the full catalog. The trimming is purely a dropdown
// scannability concern — the slash command exists precisely so
// power users can reach any model by typing its name. #1567.
for(const model of (group&&group.extra_models)||[]){
const id=_normalizeSlashSubArg(model&&model.id);
if(id) values.push(id);
}
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashModelCache=deduped;
return deduped;
}catch(_){
_slashModelCache=domValues;
return domValues;
}finally{
_slashModelCachePromise=null;
}
})();
return _slashModelCachePromise;
}
async function _loadSlashPersonalitySubArgs(force=false){
if(_slashPersonalityCache&&!force) return _slashPersonalityCache;
if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise;
_slashPersonalityCachePromise=(async()=>{
try{
const data=await api('/api/personalities');
const values=['none'];
for(const p of (data&&data.personalities)||[]){
const name=_normalizeSlashSubArg(p&&p.name);
if(name) values.push(name);
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashPersonalityCache=deduped;
return deduped;
}catch(_){
_slashPersonalityCache=['none'];
return _slashPersonalityCache;
}finally{
_slashPersonalityCachePromise=null;
}
})();
return _slashPersonalityCachePromise;
}
async function _loadSlashSkillSubArgs(force=false){
if(_slashSkillCache&&!force) return _slashSkillCache;
if(_slashSkillCachePromise&&!force) return _slashSkillCachePromise;
_slashSkillCachePromise=(async()=>{
try{
const data=await api('/api/skills');
const values=[];
for(const skill of (data&&data.skills)||[]){
const name=_normalizeSlashSubArg(skill&&skill.name);
if(name) values.push(name);
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashSkillCache=deduped;
return deduped;
}catch(_){
_slashSkillCache=null;
return [];
}finally{
_slashSkillCachePromise=null;
}
})();
return _slashSkillCachePromise;
}
function invalidateSlashSkillCaches(){
_slashSkillCache=null;
_slashSkillCachePromise=null;
_skillCommandCache=[];
_skillCommandCacheReady=false;
_skillCommandLoadPromise=null;
}
function _getSlashSubArgOptions(spec){
if(Array.isArray(spec)) return Promise.resolve(spec.slice());
if(spec==='models') return _loadSlashModelSubArgs();
if(spec==='personalities') return _loadSlashPersonalitySubArgs();
if(spec==='skills') return _loadSlashSkillSubArgs();
return Promise.resolve([]);
}
let _agentCommandCacheReady=false;
async function loadAgentCommandMetadata(force=false){
if(_agentCommandCacheReady&&!force)return _agentCommandCache||[];
if(_agentCommandCachePromise&&!force)return _agentCommandCachePromise;
_agentCommandCachePromise=(async()=>{
try{
const data=await api('/api/commands');
_agentCommandCache=Array.isArray(data&&data.commands)?data.commands:[];
}catch(_){
_agentCommandCache=[];
}finally{
_agentCommandCacheReady=true;
_agentCommandCachePromise=null;
}
return _agentCommandCache;
})();
return _agentCommandCachePromise;
}
async function getAgentCommandMetadata(name){
const needle=String(name||'').trim().toLowerCase();
if(!needle) return null;
const commands=await loadAgentCommandMetadata();
return commands.find(cmd=>{
if(String(cmd&&cmd.name||'').toLowerCase()===needle) return true;
return Array.isArray(cmd&&cmd.aliases)&&cmd.aliases.some(a=>String(a||'').toLowerCase()===needle);
})||null;
}
function cliOnlyCommandResponse(cmdName, meta){
const name=String((meta&&meta.name)||cmdName||'').trim();
const desc=String((meta&&meta.description)||'').trim();
const detail=desc?`\n\n${desc}`:'';
let extra='';
if(name==='browser'){
extra='\n\nBrowser tools in WebUI must be configured server-side with the agent/browser environment. Once configured, ask the model to use browser tools directly; `/browser` itself only works in `hermes chat`.';
}
return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`;
}
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
async function executeAgentCommand(text,_meta){
return _runAgentCommandTransport(text,_meta);
}
async function executeAgentPluginCommand(text,_meta){
Release v0.51.260 — Release IB (stage-r8) (#3614) ## Release v0.51.260 — Release IB (stage-r8) Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* | | #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* | | #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). | | #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). | | #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. | | #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. | ### Gate - Full pytest suite: **7681 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order. Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 22:21:41 +00:00
return _runAgentCommandTransport(text,_meta);
}
async function _runAgentCommandTransport(text,_meta){
const command=String(text||'').trim();
if(!command) throw new Error('command is required');
const data=await api('/api/commands/exec',{
method:'POST',
body:JSON.stringify({command})
});
return String(data&&data.output||'(no output)');
}
async function resolveBundleCommand(text,_meta){
const command=String(text||'').trim();
if(!command) throw new Error('command is required');
return api('/api/commands/bundles/resolve',{
method:'POST',
body:JSON.stringify({command})
});
}
function _activeSlashCommandOffset(text){
// Find the offset of the slash that begins the active command token.
// A command token starts with / at the beginning of the line or after
// whitespace. Excludes ~/ path tokens and mid-word slashes (URLs,
// provider/model IDs like openrouter/deepseek).
if(!text||text.indexOf('\n')!==-1) return -1;
// Scan for / at a token-initial position
for(let i=0;i<text.length;i++){
if(text[i]==='/'){
// Start of line = token-initial
if(i===0) return i;
// After whitespace = token-initial
if(/\s/.test(text[i-1])){
// Exclude ~/ path tokens
if(i+1<text.length&&text[i+1]==='~') continue;
return i;
}
}
}
return -1;
}
function _parseSlashAutocomplete(text){
const slashIdx=_activeSlashCommandOffset(text);
if(slashIdx<0) return null;
const raw=text.slice(slashIdx+1);
const hasSpace=/\s/.test(raw);
const parts=raw.split(/\s+/);
const cmdName=(parts[0]||'').toLowerCase();
const command=COMMANDS.find(c=>c.name===cmdName);
const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName];
if(!hasSpace||!subArgSource){
return {kind:'commands', query:raw};
}
const argText=raw.slice(cmdName.length).replace(/^\s+/,'');
return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText};
}
async function getSlashAutocompleteMatches(text){
const parsed=_parseSlashAutocomplete(text);
if(!parsed) return [];
if(parsed.kind==='commands') return getMatchingCommands(parsed.query);
const options=await _getSlashSubArgOptions(parsed.command.subArgs);
return options
.filter(opt=>String(opt).toLowerCase().startsWith(parsed.query))
.map(opt=>({
name:parsed.command.name,
value:String(opt),
desc:parsed.command.desc,
source:'subarg',
parent:parsed.command.name,
}));
}
function _findComposerPathToken(text,cursor){
const value=String(text||'');
const rawCursor=Number(cursor);
const pos=Number.isFinite(rawCursor)?Math.max(0,Math.min(rawCursor,value.length)):value.length;
let start=pos;
while(start>0&&!/\s/.test(value.charAt(start-1))) start-=1;
let end=pos;
while(end<value.length&&!/\s/.test(value.charAt(end))) end+=1;
const prefix=value.slice(start,pos);
if(!prefix.startsWith('~/')) return null;
return {start,end,prefix};
}
async function getComposerPathAutocompleteMatches(text,cursor){
const token=_findComposerPathToken(text,cursor);
if(!token||typeof api!=='function') return [];
const qs=new URLSearchParams({prefix:token.prefix}).toString();
const data=await api(`/api/workspaces/suggest?${qs}`);
const needle=token.prefix.toLowerCase();
return ((data&&data.suggestions)||[])
.map(path=>String(path||''))
.filter(path=>path&&path.toLowerCase().startsWith(needle))
.map(path=>({
name:path,
value:path,
desc:'Workspace path',
source:'path',
tokenStart:token.start,
tokenEnd:token.end,
}));
}
function _compressionAnchorMessageKey(m){
if(!m||!m.role||m.role==='tool') return null;
let content='';
try{
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
}catch(_){
content=String(m.content||'');
}
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
const ts=m._ts||m.timestamp||null;
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
if(!norm && !attachments && !ts) return null;
return {role:String(m.role||''), ts, text:norm, attachments};
}
// ── Command handlers ────────────────────────────────────────────────────────
function cmdHelp(){
const lines=COMMANDS.map(c=>{
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
return ` /${c.name}${usage} — ${c.desc}`;
});
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast(t('type_slash'));
}
function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
if(typeof clearCompressionUi==='function') clearCompressionUi();
renderMessages();
$('emptyState').style.display='';
showToast(t('conversation_cleared'));
}
// Find the best matching model <option> for a slash-command query.
// Returns an exact id/label match if present, otherwise the shortest option
// whose value or label contains the query. Preferring the shortest match keeps
// a specific query like "mimo-v2.5" from being shadowed by a longer variant
// such as "mimo-v2.5-pro". See issue #3368.
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
//
// #3368 follow-up: a COMPLETE versioned query (ends in a digit, e.g.
// "mimo-v2.5") must not match a variant/tier suffix (e.g. "mimo-v2.5-pro") via
// substring — that silently upgrades the user to a different (paid) tier they
// did not ask for. Such a candidate is only accepted when the query also
// matches the option's visible label as a *whole word*, or when the extra text
// continues a version number. Otherwise the longer tier is rejected here and
// surfaced as a suggestion instead.
function _looksLikeVersionedModel(query){
// e.g. "mimo-v2.5", "gpt-5.5", "claude-opus-4.6" — ends in a digit.
return /\d$/.test(String(query||''));
}
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
// Build the full /model candidate set from the catalog groups returned by
// /api/models: featured `models` PLUS the truncated `extra_models` tail. Large
// provider catalogs (e.g. a Nous Portal subscription with >25 models) only
// render a "featured" subset as <option> entries — the rest live in
// extra_models and never become <select> options (see _build_nous_featured_set
// in api/config.py). The slash command exists precisely so power users can reach
// ANY catalog model by name, the same way the CLI/autocomplete can, so /model
// must resolve against this full list — not just the rendered picker options.
// Falls back to the live <select> options when the catalog fetch failed.
// Each entry mimics the <option> shape (value + textContent) so it can be passed
// straight to _bestModelMatch / _nearestModelSuggestion. Also returns a
// value->provider_id map so an extras-only winner can be injected with the right
// provider. (#3368)
function _buildModelCandidates(sel,groups){
const options=[];
const providerMap={};
if(Array.isArray(groups)&&groups.length){
for(const g of groups){
const pid=(g&&g.provider_id)||'';
const push=m=>{
if(!m||!m.id) return;
options.push({value:m.id,textContent:m.label||m.id});
if(pid&&!(m.id in providerMap)) providerMap[m.id]=pid;
};
for(const m of (Array.isArray(g.models)?g.models:[])) push(m);
for(const m of (Array.isArray(g.extra_models)?g.extra_models:[])) push(m);
}
}
if(!options.length&&sel){
for(const o of Array.from(sel.options||[])) options.push({value:o.value,textContent:o.textContent});
}
return {options,providerMap};
}
function _bestModelMatch(options,query){
let best=null;
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
const versioned=_looksLikeVersionedModel(query);
for(const opt of options){
const value=opt.value.toLowerCase();
const text=opt.textContent.toLowerCase();
if(value===query||text===query) return opt.value;
if(value.includes(query)||text.includes(query)){
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
if(versioned){
// Reject a longer variant where the query is immediately followed by a
// tier/variant suffix ("-pro", "-flash", " pro", etc.) rather than a
// version continuation. Tested on the option value (canonical id).
const idx=value.indexOf(query);
const after=idx>=0?value.charAt(idx+query.length):'';
// after === '' → query is a suffix of value (exact-ish, allow)
// after is a digit or '.' → version continuation (allow, e.g. v2 → v2.5)
// after is '-' or other → variant/tier suffix (reject)
if(after && after!=='.' && !/\d/.test(after)) continue;
}
if(best===null||opt.value.length<best.length) best=opt.value;
}
}
return best;
}
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
// When a versioned query has no clean match, find the closest catalog option
// (a longer variant that the query is a prefix of) to offer as a suggestion,
// e.g. "mimo-v2.5" → "mimo-v2.5-pro". Returns the suggested option value, or ''.
function _nearestModelSuggestion(options,query){
let suggestion='';
for(const opt of options){
const value=opt.value.toLowerCase();
if(value.includes(query)){
const cand=opt.value;
if(!suggestion||cand.length<suggestion.length) suggestion=cand;
}
}
return suggestion;
}
async function cmdModel(args){
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
if(!sel)return;
let q=args.toLowerCase();
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
// Fetch /api/models once: it carries both the alias map AND the full catalog
// groups (featured `models` + truncated `extra_models`). Resolve aliases, then
// build the full candidate set so /model can reach ANY catalog model by name —
// not just the subset rendered as <option> entries. On large provider catalogs
// (e.g. a Nous Portal subscription) the picker only renders ~25 "featured"
// models; the rest live in extra_models and are absent from sel.options. The
// CLI resolves against the full catalog, so /model must too. (#3368)
let modelsData=null;
try {
const resp=await fetch(new URL('api/models',document.baseURI||location.href).href);
if(resp.ok){
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
modelsData=await resp.json();
const aliases=modelsData.aliases||{};
for(const [alias,modelId] of Object.entries(aliases)){
if(alias.toLowerCase()===q){
2026-05-18 03:43:01 +00:00
q=modelId.toLowerCase(); // resolve alias to real model id e.g. "deepseek/deepseek-v4-flash"
break;
}
}
}
} catch(_){/* non-critical, fall through to fuzzy match */}
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
const {options:candidates,providerMap}=_buildModelCandidates(sel,modelsData&&modelsData.groups);
2026-05-18 03:43:01 +00:00
// First: try exact match within active provider's optgroup.
// Use _findModelInDropdown (ui.js) which supports preferredProviderId.
const preferred=(S&&S.session&&S.session.model_provider)||window._activeProvider||null;
let match=(typeof _findModelInDropdown==='function')?_findModelInDropdown(q,sel,preferred):null;
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
// Fallback: fuzzy match across the FULL catalog (featured + extras), so an
// exact bare model living in the extras tail (e.g. "mimo-v2.5" alongside the
// featured "mimo-v2.5-pro") still wins — exact/shortest-match in _bestModelMatch.
2026-05-18 03:43:01 +00:00
if(!match){
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
match=_bestModelMatch(candidates,q);
}
// Fallback: if q has provider/ prefix (e.g. "deepseek/deepseek-v4-flash"),
// try the bare model name (which is how options appear for the active provider)
if(!match && q.includes('/')){
const bare=q.slice(q.lastIndexOf('/')+1);
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
match=_bestModelMatch(candidates,bare);
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
// #3368: a versioned slash-qualified query whose only near catalog entry is a
// rejected tier variant (e.g. "xiaomi/mimo-v2.5" when only "xiaomi/mimo-v2.5-pro"
// exists) must NOT silently direct-update to the invalid name — fall through to
// the no-match/"did you mean?" toast instead. The cross-provider direct-update
// path below is only for genuinely off-catalog providers with no near variant.
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
const nearSuggestion=_nearestModelSuggestion(candidates,q)||_nearestModelSuggestion(candidates,bare);
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
const versionedNoSnap=_looksLikeVersionedModel(bare)&&nearSuggestion;
// Cross-provider fallback: if still no match, the model is from a
// different provider not in the dropdown. Call /api/session/update directly.
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
if(!match && !versionedNoSnap && S&&S.session&&S.session.session_id){
const provider=q.slice(0,q.indexOf('/'));
try{
const resp=await fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
session_id:S.session.session_id,
model:q,
model_provider:provider,
}),
});
if(resp.ok){
S.session.model=q;
S.session.model_provider=provider;
if(typeof syncTopbar==='function') syncTopbar();
showToast(t('switched_to')+q);
return;
}
}catch(_){/* fall through to "no model match" */}
}
}
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
if(!match){
// #3368: when a complete versioned name (e.g. "mimo-v2.5") doesn't match
// because only a longer tier variant exists ("mimo-v2.5-pro"), don't snap
// to it — say no-match and suggest the near variant so the user can opt in.
// no_model_match already ends with an opening quote, so close it with args+".
let msg=t('no_model_match')+`${args}"`;
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
const suggestion=_nearestModelSuggestion(candidates,q);
Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465) * Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes) Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown (ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast. 34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision). * fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review) Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel: (1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?'; fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads 'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'. * fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE) Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update fallback would silently persist the invalid model + 'Switched to...'. Now gated on !versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant) still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows suggestion toast. +1 regression test. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 04:35:09 +00:00
if(suggestion){
// model_did_you_mean is a placeholder template; t() invokes it with the
// suggestion as its arg. (Calling t() without the arg renders "undefined".)
msg+=t('model_did_you_mean', suggestion);
}
showToast(msg);
return;
}
Release v0.51.231 — Release GY (stage-q1) (#3484) ## Release v0.51.231 — Release GY (stage-q1) Four independent, low-risk fixes batched into one release. All gates green. ### Fixes | PR | Author | Fix | |----|--------|-----| | #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) | | #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation | | #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) | | #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink | ### Note on #3457 The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean. ### Gate results - **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed** - **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign) - **ruff forward gate**: CLEAN (no new violations on changed lines) - **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors) - **Codex (regression)**: SAFE TO SHIP - **Opus (correctness)**: SAFE TO SHIP Closes #3368. Closes #3457. Closes #3433. Co-authored-by: pix0127 <pix0127@users.noreply.github.com> Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com> Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 16:13:01 +00:00
// The winning model may live in the extras tail (not a rendered <option>), so
// a bare `sel.value=match` would silently no-op. Inject the option with its
// provider before selecting so onchange() persists the correct model+provider
// end-to-end. _ensureModelOptionInDropdown reuses the existing option when one
// is already rendered. (#3368)
const hasOption=Array.from(sel.options||[]).some(o=>o.value===match);
if(!hasOption && typeof _ensureModelOptionInDropdown==='function'){
_ensureModelOptionInDropdown(match,sel,providerMap[match]||null);
}else{
sel.value=match;
}
await sel.onchange();
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast(t('workspace_usage'));return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;}
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242) * Polish workspace panel behavior and app dialogs * Replace remaining emoji UI glyphs with Lucide icons * Redesign composer footer around model and context controls Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill. Design credit and inspiration: Theo / T3 Code. Reference implementation: https://github.com/pingdotgg/t3code/ * Remove obsolete activity bar Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts. This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status. * Move workspace and model switching into composer footer * Move profile switching into composer footer * Refactor Hermes control center UI * Redesign control center settings modal layout Widen the modal to 860px, simplify the tab list to icon+label rows, stretch the tab column's divider to full height, lock the panel to a fixed height so switching tabs no longer resizes the outer shell, and always open on the Conversation tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Put session item actions in a dropdown * Use Hermes mark in sidebar control button * Reset control center section on close * Drop session-item left border indicator Remove the left-border accent used for active, CLI, and project rows — each state already has a dedicated cue (gold fill, cli badge, project dot), so the border was redundant. Fully round the row, add 2px bottom spacing between rows, and strip the matching JS/CSS overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase session search input vertical padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Normalise odd pixel values across UI Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid across composer chips, sidebar panels, cron list, settings, approval buttons, dropdowns, and inline message edit — eliminating the 7/9/11px drift that was making sibling elements feel subtly misaligned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite) The mobile layout regression suite (test_mobile_layout.py) requires: - #btnMobileFiles onclick=toggleMobileFiles() in topbar chips - .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports. * Improve composer footer mobile responsiveness and UX - Collapse composer chips to icon-only at <=400px viewports - Add model chip icon (CPU) so it remains tappable when labels are hidden - Show send button always (disabled state when empty, hidden during streaming) - Show context usage indicator on session load, not just after streaming - Add cancel status fallback timeout to prevent stale "Cancelling..." text - Update tests to match new send button and busy state behavior * Fix duplicate files button and broken workspace close on mobile Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle in the mobile topbar. Fix workspace panel close button calling undefined closeMobileFiles() — now calls closeWorkspacePanel(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix model chip icon vertical alignment in composer footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix workspace toggle button hidden on desktop by conflicting CSS class Remove mobile-files-btn class from #btnWorkspacePanelToggle — its display:none!important rule was overriding workspace-toggle-btn visibility on non-mobile viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix session actions dots button inaccessible on mobile sidebar Always show the session actions trigger on mobile (no hover state on touch devices) and restore right padding so text truncates with ellipsis before the dots icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer footer manage links not opening sidebar panel The "Manage profiles" and "Manage workspaces" links in the composer footer dropdowns called switchPanel() which only changes the active panel content but doesn't open the sidebar. Replaced with mobileSwitchPanel() which also opens the sidebar so the panel is actually visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Widen icon-only composer chips breakpoint from 400px to 768px Move the icon-only chip styling up into the existing max-width:768px media query so chips collapse to icon-only on tablets too, preventing composer footer overflow on mid-size screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer-left vertical scrollbar by setting overflow-y:hidden When overflow-x is set to auto, the CSS spec implicitly changes overflow-y from visible to auto, allowing a vertical scrollbar to appear from slight chip padding/border overflow. Explicitly set overflow-y:hidden to prevent this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve rebase conflicts and fix control center test assertions - Resolved 4 conflicts during rebase onto master (workspace.js, boot.js, index.html, test_sprint34.py) - Fixed test_sprint34.py: _controlSection -> _settingsSection, cc-tab -> settings-tabs (matching actual implementation) - Fixed quoting syntax error in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update version badge in System tab to v0.49.4 * docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge --------- Co-authored-by: Aron Prins <pwf.aron@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
else showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdTerminal(){
let data=null;
try{
data=await api('/api/workspaces');
if(typeof syncTerminalBackendState==='function') syncTerminalBackendState(data);
if(data&&data.terminal_remote_backend){
const msg=typeof _terminalRemoteBackendUnsupportedMessage==='function'
? _terminalRemoteBackendUnsupportedMessage()
: 'Embedded terminal is only supported for local terminal backends.';
showToast(msg,3200,'warning');
if(typeof syncTerminalButton==='function') syncTerminalButton();
return;
}
}catch(_){}
if(!S.session&&typeof newSession==='function'){
if(!S._profileSwitchWorkspace&&!S._profileDefaultWorkspace){
const first=(data&&data.workspaces||[])[0];
S._profileSwitchWorkspace=(data&&data.last)||(first&&first.path)||null;
}
await newSession();
if(typeof renderSessionList==='function') await renderSessionList();
}
if(!S.session||!S.session.workspace){
showToast(t('terminal_no_workspace_title'),2600,'warning');
if(typeof syncTerminalButton==='function') syncTerminalButton();
return;
}
if(typeof toggleComposerTerminal==='function') await toggleComposerTerminal(true);
}
async function cmdNew(){
if(typeof clearCompressionUi==='function') clearCompressionUi();
await newSession();
await renderSessionList();
$('msg').focus();
showToast(t('new_session'));
}
2026-05-12 09:32:05 +00:00
function _manualCompressionVisibleMessages(){
return (S.messages||[]).filter(m=>{
if(!m||!m.role||m.role==='tool') return false;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
}
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
});
}
function _manualCompressionSleep(ms){
return new Promise(resolve=>setTimeout(resolve, ms));
}
async function _pollManualCompressionResult(sid){
let delay=700;
while(true){
const data=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`);
if(data&&data.status==='done') return data;
if(data&&data.status==='error'){
const err=new Error(data.error||'Compression failed');
err.status=data.error_status||400;
throw err;
}
if(data&&data.status==='idle') throw new Error('Compression job is no longer available');
await _manualCompressionSleep(delay);
delay=Math.min(2000, delay+300);
}
}
async function _applyManualCompressionResult(data, focusTopic, visibleCount, commandText){
if(data&&data.session){
const currentSid=S.session&&S.session.session_id;
if(data.session.session_id&&data.session.session_id!==currentSid){
await loadSession(data.session.session_id);
}else{
S.session=data.session;
S.messages=data.session.messages||[];
S.toolCalls=data.session.tool_calls||[];
clearLiveToolCards();
2026-05-16 14:31:03 +00:00
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
2026-05-12 09:32:05 +00:00
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
syncTopbar();
renderMessages();
await renderSessionList();
updateQueueBadge(S.session.session_id);
}
}
const summary=data&&data.summary;
if(typeof setCompressionUi==='function'&&S.session){
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
// Prefer the persisted compaction handoff when it already exists in session state.
// The short summary fallback is only for environments where that message is unavailable.
const referenceText=messageRef || summaryRef;
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
setCompressionUi({
sessionId:S.session.session_id,
phase:'done',
focusTopic:effectiveFocus,
commandText:effectiveFocus?`/compress ${effectiveFocus}`:(commandText||'/compress'),
beforeCount:visibleCount,
summary:summary||null,
referenceText,
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
});
}
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
}
async function resumeManualCompressionForSession(sid){
if(!sid) return;
try{
const status=await api(`/api/session/compress/status?session_id=${encodeURIComponent(sid)}`);
if(!status||status.status!=='running') return;
const visibleMessages=_manualCompressionVisibleMessages();
const visibleCount=visibleMessages.length;
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
if(typeof setBusy==='function') setBusy(true);
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
if(typeof setCompressionUi==='function'){
setCompressionUi({
sessionId:sid,
phase:'running',
focusTopic:status.focus_topic||'',
commandText:status.focus_topic?`/compress ${status.focus_topic}`:'/compress',
beforeCount:visibleCount,
anchorVisibleIdx:Math.max(0, visibleCount-1),
anchorMessageKey,
});
}
renderMessages();
const done=await _pollManualCompressionResult(sid);
if(!S.session||S.session.session_id!==sid) return;
await _applyManualCompressionResult(done, status.focus_topic||'', visibleCount, status.focus_topic?`/compress ${status.focus_topic}`:'/compress');
}catch(e){
// No active compression job or transient server error — not a real failure.
// 404: route missed or session gone; 5xx: backend exception during status check.
if(e&&(!e.status||e.status===404||e.status>=500)) return;
2026-05-12 09:32:05 +00:00
if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){
const visibleMessages=_manualCompressionVisibleMessages();
setCompressionUi({
sessionId:sid,
phase:'error',
focusTopic:'',
commandText:'/compress',
beforeCount:visibleMessages.length,
errorText:`Compression failed: ${e.message}`,
anchorVisibleIdx:Math.max(0, visibleMessages.length-1),
anchorMessageKey:null,
});
renderMessages();
}
}finally{
if(S.session&&S.session.session_id===sid){
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
}
}
}
async function _runManualCompression(focusTopic){
if(!S.session){showToast(t('no_active_session'));return;}
let visibleCount=0;
try{
const sid=S.session.session_id;
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
return;
}
if(typeof setBusy==='function') setBusy(true);
const body={session_id:sid};
if(focusTopic) body.focus_topic=focusTopic;
2026-05-12 09:32:05 +00:00
const visibleMessages=_manualCompressionVisibleMessages();
visibleCount=visibleMessages.length;
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
if(typeof setCompressionUi==='function'){
setCompressionUi({
sessionId:S.session.session_id,
phase:'running',
focusTopic:focusTopic||'',
commandText,
beforeCount:visibleCount,
anchorVisibleIdx,
anchorMessageKey,
});
}
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
renderMessages();
2026-05-12 09:32:05 +00:00
const started=await api('/api/session/compress/start',{method:'POST',body:JSON.stringify(body)});
if(started&&started.status==='error'){
const err=new Error(started.error||'Compression failed');
err.status=started.error_status||400;
throw err;
}
2026-05-12 09:32:05 +00:00
const data=(started&&started.status==='done')?started:await _pollManualCompressionResult(sid);
await _applyManualCompressionResult(data, focusTopic, visibleCount, commandText);
}catch(e){
if(typeof setCompressionUi==='function'){
const currentSid=S.session&&S.session.session_id;
setCompressionUi({
sessionId:currentSid||'',
phase:'error',
focusTopic:(focusTopic||'').trim(),
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
errorText:`Compression failed: ${e.message}`,
anchorVisibleIdx: Math.max(0, visibleCount - 1),
anchorMessageKey:null,
});
}
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+e.message);
return;
}
if(typeof setBusy==='function') setBusy(false);
}
async function cmdCompress(args){
await _runManualCompression((args||'').trim());
}
async function cmdCompact(args){
await _runManualCompression((args||'').trim());
}
async function cmdUsage(){
const next=!window._showTokenUsage;
window._showTokenUsage=next;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
}catch(e){}
// Update the settings checkbox if the panel is open
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>(s.value||s.name).toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
if(themes.includes(val)||legacyThemes.includes(val)){
const appearance=_normalizeAppearance(
val,
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=appearance.theme;
const skinSel=$('settingsSkin');
if(skinSel)skinSel.value=appearance.skin;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
return;
}
// Check if it's a skin
if(skins.includes(val)){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsSkin');
if(sel)sel.value=appearance.skin;
const themeSel=$('settingsTheme');
if(themeSel)themeSel.value=appearance.theme;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.skin);
return;
}
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
}
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite * fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
async function cmdSkills(args){
try{
const data = await api('/api/skills');
let skills = data.skills || [];
if(args){
const q = args.toLowerCase();
skills = skills.filter(s =>
(s.name||'').toLowerCase().includes(q) ||
(s.description||'').toLowerCase().includes(q) ||
(s.category||'').toLowerCase().includes(q)
);
}
if(!skills.length){
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
// Group by category
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
lines.push(` \`${s.name}\`${desc}`);
});
lines.push('');
}
const header = args
? `Skills matching "${args}" (${skills.length}):\n\n`
: `Available skills (${skills.length}):\n\n`;
S.messages.push({role:'assistant', content: header + lines.join('\n')});
renderMessages();
showToast(t('type_slash'));
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}
async function cmdUse(args){
if(!args){
S.messages.push({role:'assistant',content:'Usage: `/use <skill-name>` — forces the agent to consult that skill before its next response.'});
renderMessages();
return;
}
let resolve;
const pending = {sessionId:S.session&&S.session.session_id||null,promise:null};
pending.promise = new Promise(r => { resolve = r; });
_forcedSkillDirectivePending = pending;
const isCurrentSession = () => !pending.sessionId || (S.session&&S.session.session_id)===pending.sessionId;
try{
const data = await api('/api/skills');
const skills = data.skills || [];
const match = skills.find(s => (s.name||'').toLowerCase() === args.toLowerCase());
if(!match){
resolve(null);
if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
if(isCurrentSession()){
const msg = {role:'assistant', content:`No skill named \`${args}\`. Use \`/skills\` to see available skills.`};
S.messages.push(msg); renderMessages();
}
return;
}
const detail = await api(`/api/skills/content?name=${encodeURIComponent(match.name)}`);
const skillContent = detail&&typeof detail.content==='string' ? detail.content.trim() : '';
if(!skillContent) throw new Error(`Skill \`${match.name}\` has no readable content.`);
const directive = `[USER OVERRIDE] You MUST follow the skill '${match.name}' content provided below before responding to the next message.`;
resolve({name:match.name,directive,content:skillContent});
if(isCurrentSession()){
S.messages.push({role:'assistant', content:`Next turn: skill \`${match.name}\` will be forced.`});
renderMessages();
}
showToast(`Skill \`${match.name}\` will be used for next turn.`);
}catch(e){
resolve(null);
if(_forcedSkillDirectivePending===pending)_forcedSkillDirectivePending = null;
showToast('Failed to load skills: '+e.message);
}
}
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
if(!args){
// List available personalities
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')});
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
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>
2026-04-22 06:08:24 +00:00
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
renderMessages();
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
feat: /personality slash command with backend integration (#143) * feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:16:37 +00:00
}
async function cmdStop(){
if(!S.session){showToast(t('no_active_session'));return;}
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
fix(webui): stop false clarify-unavailable toast; add interrupt provenance (#5345) /api/clarify/pending always returns HTTP 200 when present (returns {"pending": null} for an unknown session — it never 404s). The front-end clarify poller warned "Clarify endpoint unavailable. Please restart server." on ANY caught error whose message merely contained "404" or "not found", so an unrelated stale-session 404 ("Session not found", e.g. an old-profile session polling briefly after a profile switch) or a transient error produced a misleading missing-endpoint toast that pointed operators at the wrong layer. Clarify polling now branches on the structured HTTP status that api() attaches to the thrown Error (err.status): - 404 "Session not found" -> handled as a stale-session poll (stop + hide card silently), no toast; - restart-server warning fires only on a genuine route-not-found 404 whose body is NOT session-scoped; - poll failures are logged with path, status, polling session id, and current session id for diagnosis. Interrupt provenance: cancelStream()/cancelSessionStream() now log a '[stream] cancel requested' line with the trigger reason (composer-stop / slash-stop / slash-interrupt / busy-interrupt / sidebar-stop). Passive UI lifecycle events (session switch, tab hide, page unload) already tear down only the local SSE transport via closeLiveStream() and never call /api/chat/cancel — only explicit Stop/interrupt paths interrupt the backend agent/tool run. This is confirmed by test_clarify_pending_never_404s locking the handler shape. Supersedes #5343 (which handled only the profile-switch sub-case and kept the broad message-scrape). Adds tests/test_issue5345_*.py (9 tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com> Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
2026-07-01 15:25:49 +00:00
if(typeof cancelStream==='function'){await cancelStream('slash-stop');showToast(t('stream_stopped'));}
else showToast(t('cancel_unavailable'));
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
2026-05-08 02:32:06 +00:00
async function cmdGoal(args){
if(!S.session){await newSession();await renderSessionList();}
if(!S.session||!S.session.session_id){showToast(t('no_active_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/goal',{method:'POST',body:JSON.stringify({
session_id:activeSid,
args:args||'',
workspace:S.session.workspace,
model:S.session.model||($('modelSelect')&&$('modelSelect').value)||'',
model_provider:S.session.model_provider||null,
profile:S.activeProfile||S.session.profile||'default',
})});
const msg = (() => {
const raw = String((r && r.message) || '').trim();
const key = String((r && r.message_key) || '').trim();
const args = Array.isArray(r && r.message_args) ? r.message_args : [];
if (raw.includes('\n')) return raw;
if (key && typeof t === 'function') {
const translated = String(t(key, ...args));
if (translated && translated !== key) return translated;
}
return raw;
})();
2026-05-08 02:32:06 +00:00
if(msg){
S.messages.push({role:'assistant',content:msg,_ts:Date.now()/1000,_goalStatus:true,_transient:true});
renderMessages({preserveScroll:true});
showToast(msg.split('\n')[0],2600);
}
if(!r||!r.stream_id)return;
S.toolCalls=[];
if(typeof clearLiveToolCards==='function')clearLiveToolCards();
appendThinking();setBusy(true);
setComposerStatus(t('goal_working_toward'));
2026-05-08 02:32:06 +00:00
S.activeStreamId=r.stream_id;
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id=r.stream_id;
if(typeof r.pending_started_at==='number')S.session.pending_started_at=r.pending_started_at;
if(r.effective_model)S.session.model=r.effective_model;
if(r.effective_model_provider)S.session.model_provider=r.effective_model_provider;
}
INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[],toolCalls:[]};
if(typeof markInflight==='function')markInflight(activeSid,r.stream_id);
if(typeof saveInflightState==='function')saveInflightState(activeSid,{streamId:r.stream_id,messages:INFLIGHT[activeSid].messages,uploaded:[],toolCalls:[]});
startApprovalPolling(activeSid);
startClarifyPolling(activeSid);
if(typeof _fetchYoloState==='function')_fetchYoloState(activeSid);
attachLiveStream(activeSid,r.stream_id,[]);
if(typeof renderSessionList==='function')void renderSessionList();
}catch(e){
const err=String((e&&e.message)||e||'Goal command failed');
S.messages.push({role:'assistant',content:`**Goal command failed:** ${err}`,_ts:Date.now()/1000,_error:true});
renderMessages({preserveScroll:true});
showToast(err,3000);
}
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
// ── Busy-input mode commands ──────────────────────────────────────────────
fix(settings): reconcile #5145 rename+steer-flip onto master's #5170 mirror Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the default from 'queue' to 'steer') onto current origin/master WITHOUT dropping the shipped #5170 localStorage persistence mirror. Rename the mirror machinery to the new setting name for consistency: _BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged) _normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer') _persistBusyInputMode -> _persistDefaultMessageMode _readPersistedBusyInputMode -> _readPersistedDefaultMessageMode window._busyInputMode -> window._defaultMessageMode (+ renamed exports) localStorage: write the new 'hermes-default-message-mode' key; read it with a fallback to the legacy 'hermes-busy-input-mode' key so an existing user's persisted preference survives the rename. Preserve #5170 behavior at every mirror site under the new names: - boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...) - boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode() (NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when the server is unreachable; do not regress #5167/#5132) - preferences autosave, settings-panel load, and _applySavedSettingsUi all persist through _persistDefaultMessageMode(...) Tests updated for the rename while keeping the persistence-behavior assertions (test_1062, test_5145, test_5167); test_5167 gains explicit guards that the load-failure path reads the persisted pref and never hardcodes a literal mode, plus autosave/panel-load mirror-write coverage. Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-02 05:01:27 +00:00
// These commands let users override the default message mode setting for a
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
// specific message. They are only meaningful while the agent is running.
/**
* /queue <message> — Explicitly queue a message for the next turn.
fix(settings): reconcile #5145 rename+steer-flip onto master's #5170 mirror Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the default from 'queue' to 'steer') onto current origin/master WITHOUT dropping the shipped #5170 localStorage persistence mirror. Rename the mirror machinery to the new setting name for consistency: _BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged) _normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer') _persistBusyInputMode -> _persistDefaultMessageMode _readPersistedBusyInputMode -> _readPersistedDefaultMessageMode window._busyInputMode -> window._defaultMessageMode (+ renamed exports) localStorage: write the new 'hermes-default-message-mode' key; read it with a fallback to the legacy 'hermes-busy-input-mode' key so an existing user's persisted preference survives the rename. Preserve #5170 behavior at every mirror site under the new names: - boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...) - boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode() (NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when the server is unreachable; do not regress #5167/#5132) - preferences autosave, settings-panel load, and _applySavedSettingsUi all persist through _persistDefaultMessageMode(...) Tests updated for the rename while keeping the persistence-behavior assertions (test_1062, test_5145, test_5167); test_5167 gains explicit guards that the load-failure path reads the persisted pref and never hardcodes a literal mode, plus autosave/panel-load mirror-write coverage. Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-02 05:01:27 +00:00
* Works regardless of the default message mode setting.
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
*/
async function cmdQueue(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_queue_no_msg'));return;}
v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077) * fix(commands): /queue /interrupt /steer send normally when agent is idle When the agent is not running, these three commands now fall through to a direct send() call (setting the input value and invoking send()) instead of showing an error toast. This matches CLI behaviour — the commands are mode-sensitive: they operate as queue/interrupt/steer when busy, and as normal sends when idle. Before: /queue hello → "No active task — just send normally" (toast, nothing sent) /steer hello → "No active task to stop." (misleading + nothing sent) /interrupt hi → "No active task to stop." (nothing sent) After: /queue hello → message sent immediately (same as typing and pressing Enter) /steer hello → message sent immediately /interrupt hi → message sent immediately Note: /stop when idle still shows "No active task" — that one is correct since stopping nothing is always an error. 15 new tests in test_cmd_idle_fallback.py covering the idle path for all three commands and verifying the active-session paths are unchanged. * test(commands): update stale test doc — /queue idle now sends, not rejects test_cmd_queue_requires_busy was written before the idle-send fallback existed. Its docstring said "/queue while not busy is a usage error" and the assertion message said "reject if idle" — both accurate for the old toast-and-return behaviour but wrong after this PR. The test assertion itself (`"if(!S.busy)" in body`) still passes because the idle guard still exists; it just routes to send() instead of a toast. Updating the name and copy to accurately describe what the code now does, so the test reads as documentation rather than as a contradiction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.217 release notes and version bump --------- 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>
2026-04-26 04:35:51 +00:00
// If nothing is running, /queue <msg> just sends like a normal message
if(!S.busy){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
if(!S.session){showToast(t('no_active_session'));return;}
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
showToast(t('cmd_queue_confirm'),2000);
}
/**
* /interrupt <message> — Cancel the current turn and send a new message.
* Calls cancelStream() then queues the message so the drain picks it up.
*/
async function cmdInterrupt(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_interrupt_no_msg'));return;}
v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077) * fix(commands): /queue /interrupt /steer send normally when agent is idle When the agent is not running, these three commands now fall through to a direct send() call (setting the input value and invoking send()) instead of showing an error toast. This matches CLI behaviour — the commands are mode-sensitive: they operate as queue/interrupt/steer when busy, and as normal sends when idle. Before: /queue hello → "No active task — just send normally" (toast, nothing sent) /steer hello → "No active task to stop." (misleading + nothing sent) /interrupt hi → "No active task to stop." (nothing sent) After: /queue hello → message sent immediately (same as typing and pressing Enter) /steer hello → message sent immediately /interrupt hi → message sent immediately Note: /stop when idle still shows "No active task" — that one is correct since stopping nothing is always an error. 15 new tests in test_cmd_idle_fallback.py covering the idle path for all three commands and verifying the active-session paths are unchanged. * test(commands): update stale test doc — /queue idle now sends, not rejects test_cmd_queue_requires_busy was written before the idle-send fallback existed. Its docstring said "/queue while not busy is a usage error" and the assertion message said "reject if idle" — both accurate for the old toast-and-return behaviour but wrong after this PR. The test assertion itself (`"if(!S.busy)" in body`) still passes because the idle guard still exists; it just routes to send() instead of a toast. Updating the name and copy to accurately describe what the code now does, so the test reads as documentation rather than as a contradiction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.217 release notes and version bump --------- 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>
2026-04-26 04:35:51 +00:00
// If nothing is running, /interrupt <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
if(!S.session){showToast(t('no_active_session'));return;}
// Queue the message first (before cancel sets busy=false and drains)
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
// Cancel the active stream; setBusy(false) will drain the queue
fix(webui): stop false clarify-unavailable toast; add interrupt provenance (#5345) /api/clarify/pending always returns HTTP 200 when present (returns {"pending": null} for an unknown session — it never 404s). The front-end clarify poller warned "Clarify endpoint unavailable. Please restart server." on ANY caught error whose message merely contained "404" or "not found", so an unrelated stale-session 404 ("Session not found", e.g. an old-profile session polling briefly after a profile switch) or a transient error produced a misleading missing-endpoint toast that pointed operators at the wrong layer. Clarify polling now branches on the structured HTTP status that api() attaches to the thrown Error (err.status): - 404 "Session not found" -> handled as a stale-session poll (stop + hide card silently), no toast; - restart-server warning fires only on a genuine route-not-found 404 whose body is NOT session-scoped; - poll failures are logged with path, status, polling session id, and current session id for diagnosis. Interrupt provenance: cancelStream()/cancelSessionStream() now log a '[stream] cancel requested' line with the trigger reason (composer-stop / slash-stop / slash-interrupt / busy-interrupt / sidebar-stop). Passive UI lifecycle events (session switch, tab hide, page unload) already tear down only the local SSE transport via closeLiveStream() and never call /api/chat/cancel — only explicit Stop/interrupt paths interrupt the backend agent/tool run. This is confirmed by test_clarify_pending_never_404s locking the handler shape. Supersedes #5343 (which handled only the profile-switch sub-case and kept the broad message-scrape). Adds tests/test_issue5345_*.py (9 tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com> Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
2026-07-01 15:25:49 +00:00
if(typeof cancelStream==='function'){await cancelStream('slash-interrupt');}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
showToast(t('cmd_interrupt_confirm'),2000);
}
/**
* /steer <message> — Inject a steering hint mid-task without interrupting.
*
* Calls POST /api/chat/steer which looks up the cached AIAgent for this
* session and calls agent.steer(text). The agent's run loop appends the
* steer text to the next tool-result message so the model sees it on its
* next iteration — same pathway as the CLI's /steer command.
*
* Leaves the active stream alone when the agent isn't running, isn't cached,
* or doesn't support steer (older hermes-agent versions). The failed steer text
* is restored to the composer so the user can choose Queue or Interrupt
* explicitly instead of WebUI silently cancelling the current run.
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
*/
async function cmdSteer(args){
const msg=(args||'').trim();
const hasPendingFiles=typeof S!=='undefined'&&Array.isArray(S.pendingFiles)&&S.pendingFiles.length>0;
if(!msg&&!hasPendingFiles){showToast(t('cmd_steer_no_msg'));return;}
v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077) * fix(commands): /queue /interrupt /steer send normally when agent is idle When the agent is not running, these three commands now fall through to a direct send() call (setting the input value and invoking send()) instead of showing an error toast. This matches CLI behaviour — the commands are mode-sensitive: they operate as queue/interrupt/steer when busy, and as normal sends when idle. Before: /queue hello → "No active task — just send normally" (toast, nothing sent) /steer hello → "No active task to stop." (misleading + nothing sent) /interrupt hi → "No active task to stop." (nothing sent) After: /queue hello → message sent immediately (same as typing and pressing Enter) /steer hello → message sent immediately /interrupt hi → message sent immediately Note: /stop when idle still shows "No active task" — that one is correct since stopping nothing is always an error. 15 new tests in test_cmd_idle_fallback.py covering the idle path for all three commands and verifying the active-session paths are unchanged. * test(commands): update stale test doc — /queue idle now sends, not rejects test_cmd_queue_requires_busy was written before the idle-send fallback existed. Its docstring said "/queue while not busy is a usage error" and the assertion message said "reject if idle" — both accurate for the old toast-and-return behaviour but wrong after this PR. The test assertion itself (`"if(!S.busy)" in body`) still passes because the idle guard still exists; it just routes to send() instead of a toast. Updating the name and copy to accurately describe what the code now does, so the test reads as documentation rather than as a contradiction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.217 release notes and version bump --------- 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>
2026-04-26 04:35:51 +00:00
// If nothing is running, /steer <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
if(!S.session){showToast(t('no_active_session'));return;}
await _trySteer(msg, /*explicitSteer=*/true);
}
function _steerFailureMessageKey(fallback) {
if(fallback==='gateway_steer_queued')return 'steer_fail_no_cached_agent';
const key = 'steer_fail_' + (fallback || 'unknown');
return (typeof LOCALES !== 'undefined' && LOCALES.en && LOCALES.en[key])
? key : 'steer_fail_unknown';
}
function _showSteerIndicator(text){
const inner=document.getElementById('msgInner');
if(!inner) return;
// Remove any existing steer indicator
const old=inner.querySelector('.steer-indicator');
if(old) old.remove();
const el=document.createElement('div');
el.className='steer-indicator';
const badge=document.createElement('span');
badge.className='steer-badge';
badge.textContent='Steer';
const body=document.createElement('span');
body.className='steer-body';
body.textContent=text.length>120?text.slice(0,117)+'…':text;
el.appendChild(badge);
el.appendChild(body);
inner.appendChild(el);
if(typeof scrollToBottom==='function') scrollToBottom();
}
function _showSteerRecovery(msg, explicitSteer, fallback) {
const inner = document.getElementById('msgInner');
if (!inner) return;
const old = inner.querySelector('.steer-recovery');
if (old) old.remove();
const el = document.createElement('div');
el.className = 'steer-recovery';
const label = document.createElement('span');
label.className = 'steer-recovery-label';
label.textContent = t(_steerFailureMessageKey(fallback));
el.appendChild(label);
const retryBtn = document.createElement('button');
retryBtn.className = 'steer-recovery-retry';
retryBtn.textContent = t(_steerFallbackIsDeadRun(fallback)?'clarify_send':'steer_recovery_retry');
retryBtn.addEventListener('click', () => {
el.remove();
if(_steerFallbackIsDeadRun(fallback)&&typeof send==='function'){
if(explicitSteer){
const inp=$('msg');
if(inp){
inp.value=String(msg||'').trim();
if(typeof autoResize==='function')autoResize();
}
}
void send({literalSlash:true}).catch(console.error);
}else{
void _trySteer(msg, explicitSteer).catch(console.error);
}
});
el.appendChild(retryBtn);
const dismissBtn = document.createElement('button');
dismissBtn.className = 'steer-recovery-dismiss';
dismissBtn.textContent = t('steer_recovery_dismiss');
dismissBtn.addEventListener('click', () => el.remove());
el.appendChild(dismissBtn);
inner.appendChild(el);
if (typeof scrollToBottom === 'function') scrollToBottom();
}
/**
fix(settings): reconcile #5145 rename+steer-flip onto master's #5170 mirror Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the default from 'queue' to 'steer') onto current origin/master WITHOUT dropping the shipped #5170 localStorage persistence mirror. Rename the mirror machinery to the new setting name for consistency: _BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged) _normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer') _persistBusyInputMode -> _persistDefaultMessageMode _readPersistedBusyInputMode -> _readPersistedDefaultMessageMode window._busyInputMode -> window._defaultMessageMode (+ renamed exports) localStorage: write the new 'hermes-default-message-mode' key; read it with a fallback to the legacy 'hermes-busy-input-mode' key so an existing user's persisted preference survives the rename. Preserve #5170 behavior at every mirror site under the new names: - boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...) - boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode() (NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when the server is unreachable; do not regress #5167/#5132) - preferences autosave, settings-panel load, and _applySavedSettingsUi all persist through _persistDefaultMessageMode(...) Tests updated for the rename while keeping the persistence-behavior assertions (test_1062, test_5145, test_5167); test_5167 gains explicit guards that the load-failure path reads the persisted pref and never hardcodes a literal mode, plus autosave/panel-load mirror-write coverage. Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-02 05:01:27 +00:00
* Shared implementation for /steer and the default_message_mode='steer' path.
*
* Tries the real steer endpoint first. On any non-accept response (no cached
* agent, agent lacks steer, stream dead, etc.) it restores the draft and keeps
* the active stream running. Steer belongs to the active run; a failed Steer
* must not be silently upgraded into Queue, Interrupt, or Stop-and-send.
*
* @param {string} msg - The steer text.
* @param {boolean} explicitSteer - True if the user explicitly invoked /steer
* (vs the busy-mode auto-fallback). Affects draft restore prefix only;
* toast wording is determined by the failure reason code.
* @returns {Promise<boolean>} true when the steer was delivered, false when the
* draft was restored and the active stream was left untouched.
*/
function _steerUploadedAttachmentPaths(uploaded){
if(!Array.isArray(uploaded))return[];
return uploaded.map(u=>{
if(!u)return'';
if(typeof u==='string')return u;
return u.path||u.name||u.filename||'';
}).map(v=>String(v||'').trim()).filter(Boolean);
}
function _steerOwnerIsCurrent(ownerSid){
return !!(ownerSid&&typeof S!=='undefined'&&S.session&&S.session.session_id===ownerSid);
}
function _steerFallbackIsDeadRun(fallback){
return fallback==='stream_dead';
}
function _steerOwnerStreamIsCurrent(ownerSid, ownerStreamId){
if(!_steerOwnerIsCurrent(ownerSid)||typeof S==='undefined'||!ownerStreamId)return false;
const activeIds=[S.activeStreamId,S.session&&S.session.active_stream_id].filter(Boolean).map(String);
return activeIds.length>0&&activeIds.every(id=>id===String(ownerStreamId));
}
function _steerClearCurrentOwnerDeadRun(ownerSid, ownerStreamId){
if(!_steerOwnerStreamIsCurrent(ownerSid,ownerStreamId))return false;
let changed=false;
if(S.busy){S.busy=false;changed=true;}
if(S.activeStreamId){S.activeStreamId=null;changed=true;}
if(S.session&&S.session.active_stream_id){S.session.active_stream_id=null;changed=true;}
if(typeof INFLIGHT!=='undefined'&&INFLIGHT&&INFLIGHT[ownerSid]){
delete INFLIGHT[ownerSid];
changed=true;
}
if(changed&&typeof clearInflightState==='function')clearInflightState(ownerSid);
if(changed){
// Finish the stale-busy cleanup idiom the rest of the app uses so a recovered
// dead run leaves no lingering status line, elapsed timer, or streaming badge
// (#5744 UX-gate). Deliberately does NOT call setBusy(false): its queue-drain
// would clobber the draft text this recovery path restores.
if(typeof setStatus==='function')setStatus('');
if(typeof setComposerStatus==='function')setComposerStatus('');
if(typeof _clearActivityElapsedTimer==='function')_clearActivityElapsedTimer();
if(typeof clearOptimisticSessionStreaming==='function')clearOptimisticSessionStreaming(ownerSid);
}
if(changed&&typeof updateSendBtn==='function')updateSendBtn();
return changed;
}
function _steerSetComposerStatusForOwner(ownerSid,text){
if(_steerOwnerIsCurrent(ownerSid)&&typeof setComposerStatus==='function')setComposerStatus(text);
}
function _steerRestoreText(originalMsg, explicitSteer){
return explicitSteer?`/steer ${originalMsg}`:originalMsg;
}
function _steerIndicatorText(originalMsg, filesSnapshot){
const text=String(originalMsg||'').trim();
if(text)return text;
const names=(Array.isArray(filesSnapshot)?filesSnapshot:[])
.map(f=>f&&(f.name||f.filename||f.path||''))
.map(v=>String(v||'').trim())
.filter(Boolean);
return names.length?`Attached files: ${names.join(', ')}`:'Attached files';
}
async function _steerPersistDraftForOwner(ownerSid, originalMsg, explicitSteer, filesSnapshot){
if(!ownerSid||typeof _saveComposerDraftNow!=='function')return;
await _saveComposerDraftNow(ownerSid,_steerRestoreText(originalMsg,explicitSteer),filesSnapshot);
}
// #5459 gate: cache successful steer uploads by owner session so a failed-steer
// RETRY reuses the uploaded paths instead of re-uploading the same File objects.
// Keyed by ownerSid; invalidated when the staged file set changes or on accepted
// steer (see _steerUploadCacheMatches / clearing below).
let _steerUploadCache = null; // { sid, sig, paths }
function _steerFilesSignature(files){
try{
return (Array.isArray(files)?files:[]).map(f=>f&&(f.name+':'+(f.size||0)+':'+(f.lastModified||0))).join('|');
}catch(_){return String(Date.now());}
}
async function _steerTextWithPendingFiles(msg, ownerSid, filesSnapshot){
const base=String(msg||'').trim();
const pendingFiles=Array.isArray(filesSnapshot)?filesSnapshot.filter(Boolean):[];
if(!pendingFiles.length)return base;
if(typeof uploadPendingFiles!=='function')return base;
const sig=_steerFilesSignature(pendingFiles);
// Reuse a prior successful upload for the same session + identical staged file
// set (a steer that failed and is being retried) — don't upload twice.
let paths=null;
if(_steerUploadCache&&_steerUploadCache.sid===ownerSid&&_steerUploadCache.sig===sig&&Array.isArray(_steerUploadCache.paths)&&_steerUploadCache.paths.length){
paths=_steerUploadCache.paths;
}else{
_steerSetComposerStatusForOwner(ownerSid,t('uploading')||'Uploading…');
let uploaded=[];
try{
// Keep File objects staged until /api/chat/steer confirms acceptance. If
// steer falls back, the draft and chips stay available for Queue/Interrupt.
uploaded=await uploadPendingFiles({clearPending:false,sessionId:ownerSid,files:pendingFiles});
}finally{
_steerSetComposerStatusForOwner(ownerSid,'');
}
paths=_steerUploadedAttachmentPaths(uploaded);
if(paths.length) _steerUploadCache={sid:ownerSid,sig,paths};
}
if(!paths||!paths.length)return base;
const note=`[Attached files for this steer: ${paths.join(', ')}]\nUse the file tools/read_file to inspect these documents if needed.`;
return base?`${base}\n\n${note}`:note;
}
async function _trySteer(msg, explicitSteer){
let result=null;
const originalMsg=String(msg||'').trim();
const ownerSid=(typeof S!=='undefined'&&S.session&&S.session.session_id)||null;
const ownerStreamId=(typeof S!=='undefined'&&(S.activeStreamId||(S.session&&S.session.active_stream_id)))||null;
const pendingFilesSnapshot=typeof S!=='undefined'&&Array.isArray(S.pendingFiles)?[...S.pendingFiles]:[];
const ownerProfile=typeof S!=='undefined'&&(S.activeProfile||'default');
const ownerModelState=typeof _chatPayloadModelState==='function'
? _chatPayloadModelState()
: {model:(typeof S!=='undefined'&&S.session&&S.session.model)||'',model_provider:(typeof S!=='undefined'&&S.session&&S.session.model_provider)||''};
if(!ownerSid){showToast(t('no_active_session'));return false;}
let steerText=originalMsg;
try{
steerText=await _steerTextWithPendingFiles(originalMsg,ownerSid,pendingFilesSnapshot);
}catch(e){
if(_steerOwnerIsCurrent(ownerSid)){
const inp=$('msg');
if(inp){
inp.value=_steerRestoreText(originalMsg,explicitSteer);
if(typeof autoResize==='function')autoResize();
}
if(typeof renderTray==='function')renderTray();
}else{
await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot);
}
_steerSetComposerStatusForOwner(ownerSid,'');
showToast(`${t('upload_failed')}${e&&e.message?e.message:e}`,3500);
return false;
}
if(!steerText){
if(_steerOwnerIsCurrent(ownerSid)){
const inp=$('msg');
if(inp){
inp.value=_steerRestoreText(originalMsg,explicitSteer);
if(typeof autoResize==='function')autoResize();
}
if(typeof renderTray==='function')renderTray();
}else{
await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot);
}
showToast(t('cmd_steer_no_msg'));
return false;
}
try{
result=await api('/api/chat/steer',{
method:'POST',
body:JSON.stringify({session_id:ownerSid,text:steerText}),
});
}catch(e){
// Network or server error — keep the active stream running and restore the draft.
result={accepted:false, fallback:'network_error'};
}
if(result&&result.accepted){
// The captured files+text were delivered to ownerSid — clear that session's
// draft (it may not be the live session anymore if the user switched during
// the upload/API await, which is fine: we're clearing the OWNER's draft).
_steerUploadCache=null; // delivered — invalidate the retry cache
if(ownerSid&&typeof _clearComposerDraft==='function') _clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot);
// Show a transient steer indicator in the chat (NOT in S.messages — it must
// survive the done event's S.messages=d.session.messages replacement).
// The indicator self-removes when the turn completes (done/cancel/error
// all call renderMessages which rebuilds msgInner). Only mutate the visible
// tray/DOM if the user is still looking at the owning session.
if(_steerOwnerIsCurrent(ownerSid)){
// Remove ONLY the files we captured+delivered, by object identity, so any
// files staged during the upload/API await are preserved (#5459 gate).
if(typeof S!=='undefined'&&Array.isArray(S.pendingFiles)&&S.pendingFiles.length&&pendingFilesSnapshot.length){
const _delivered=new Set(pendingFilesSnapshot);
const _remaining=S.pendingFiles.filter(f=>!_delivered.has(f));
if(_remaining.length!==S.pendingFiles.length){S.pendingFiles=_remaining;if(typeof renderTray==='function')renderTray();}
}
_showSteerIndicator(_steerIndicatorText(originalMsg,pendingFilesSnapshot));
}
showToast(t('cmd_steer_delivered'),2500);
return true;
}
if(result&&result.fallback==='gateway_steer_queued'&&typeof queueSessionMessage==='function'){
_steerUploadCache=null;
queueSessionMessage(ownerSid,{
text:originalMsg,
files:pendingFilesSnapshot,
model:ownerModelState.model,
model_provider:ownerModelState.model_provider,
profile:ownerProfile,
});
if(typeof updateQueueBadge==='function')updateQueueBadge(ownerSid);
if(ownerSid&&typeof _clearComposerDraft==='function') _clearComposerDraft(ownerSid,_steerRestoreText(originalMsg,explicitSteer),pendingFilesSnapshot);
if(_steerOwnerIsCurrent(ownerSid)&&typeof S!=='undefined'&&Array.isArray(S.pendingFiles)&&S.pendingFiles.length&&pendingFilesSnapshot.length){
const _queued=new Set(pendingFilesSnapshot);
const _remaining=S.pendingFiles.filter(f=>!_queued.has(f));
if(_remaining.length!==S.pendingFiles.length){S.pendingFiles=_remaining;if(typeof renderTray==='function')renderTray();}
}
showToast(t('steer_leftover_queued'),3000);
return true;
}
// Do not fall back to interrupt: Steer failure is not permission to cancel
// the active run. Restore the draft so the user can explicitly Queue or
// Interrupt if that is what they want next. Pending files remain staged.
const fallbackCode = result && result.fallback;
const deadRunFallback = _steerFallbackIsDeadRun(fallbackCode);
const applyCurrentFailure = !deadRunFallback||_steerOwnerStreamIsCurrent(ownerSid,ownerStreamId);
if(_steerOwnerIsCurrent(ownerSid)&&applyCurrentFailure){
const inp=$('msg');
if(inp){
inp.value=_steerRestoreText(originalMsg,explicitSteer);
if(typeof autoResize==='function')autoResize();
}
if(typeof renderTray==='function')renderTray();
}else{
await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot);
}
const clearedDeadRun=deadRunFallback&&_steerClearCurrentOwnerDeadRun(ownerSid,ownerStreamId);
if(!deadRunFallback||applyCurrentFailure)showToast(t(_steerFailureMessageKey(fallbackCode)), 3500);
if(_steerOwnerIsCurrent(ownerSid)&&(!deadRunFallback||clearedDeadRun)) _showSteerRecovery(originalMsg, explicitSteer, fallbackCode);
return false;
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067) * feat: busy input modes with queue/interrupt/steer slash commands - Add busy_input_mode setting (queue/interrupt/steer) to config defaults - Add /queue, /interrupt, /steer slash commands with handlers - Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior) - Add settings dropdown in settings panel with load/save/apply wiring - Initialize window._busyInputMode at boot and on settings save - Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant) Addresses #720 * test: 17 regression tests for busy_input_mode + slash commands PR description noted manual testing only. Added structural tests matching the pattern used by recent contributor PRs (#1010, #1011, #1018, #1022, #1058) so future refactors don't silently regress the wiring: Backend (api/config.py): - default 'queue' is set in _DEFAULT_SETTINGS - enum validator restricts to {queue, interrupt, steer} Slash commands (static/commands.js): - /queue, /interrupt, /steer all registered with correct fns - /interrupt and /steer set noEcho:true (the queued payload becomes the visible turn, not the slash invocation) - cmdQueue requires S.busy - cmdInterrupt + cmdSteer call queueSessionMessage before cancelStream (otherwise the drain has nothing to pick up) send() busy branch (static/messages.js): - reads window._busyInputMode - calls cancelStream on interrupt/steer - queues before cancelling (ordering invariant) Boot init + panels.js wiring (static/boot.js, static/panels.js): - both success and fallback paths set window._busyInputMode - load/save/apply path threads busy_input_mode through i18n (static/i18n.js): - all 17 new keys present in each of the 6 locale blocks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers 1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text as a user bubble, then the drain would send the queued message, causing a double-bubble in the conversation (#840 pattern). 2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue payload but never cleared S.pendingFiles or called renderTray(). Staged files would remain in the tray and be re-attached on the next send(), duplicating attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge(). 3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only interrupt + steer), now documents that all three must set noEcho:true. 4. test_slash_commands_clear_pending_files: new test that all three handlers clear S.pendingFiles and call renderTray() after enqueuing. Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * docs: v0.50.214 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
}
async function cmdTitle(args){
if(!S.session){showToast(t('no_active_session'));return;}
const name=(args||'').trim();
if(!name){
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
renderMessages();return;
}
try{
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
if(r&&r.error){showToast(r.error);return;}
S.session.title=(r&&r.session&&r.session.title)||name;
if(typeof syncTopbar==='function')syncTopbar();
if(typeof renderSessionList==='function')renderSessionList();
showToast(`${t('title_set')} "${S.session.title}"`);
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>
2026-04-22 06:08:24 +00:00
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
renderMessages();
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdRetry(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
}catch(e){showToast(t('retry_failed')+e.message);}
}
async function cmdUndo(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function undoLastExchange(){await cmdUndo();}
async function cmdBtw(args){
if(!S.session){showToast(t('no_active_session'));return;}
const question=(args||'').trim();
if(!question){showToast(t('cmd_btw_usage'));return;}
showToast(t('btw_asking'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
if(r&&r.error){showToast(r.error);return;}
// Connect to the ephemeral SSE stream
const streamId=r.stream_id;
const parentSid=r.parent_session_id;
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
}catch(e){showToast(t('btw_failed')+e.message);}
}
async function cmdBackground(args){
if(!S.session){showToast(t('no_active_session'));return;}
const prompt=(args||'').trim();
if(!prompt){showToast(t('cmd_background_usage'));return;}
showToast(t('bg_running'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
if(r&&r.error){showToast(r.error);return;}
// Show background badge and start polling
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
}catch(e){showToast(t('bg_failed')+e.message);}
}
2026-05-04 23:58:54 +00:00
function _formatStatusTimestamp(value){
if(value===undefined||value===null||value==='') return t('status_unknown');
let date;
if(typeof value==='number') date=new Date(value < 1000000000000 ? value*1000 : value);
else date=new Date(value);
if(Number.isNaN(date.getTime())) return t('status_unknown');
return date.toLocaleString();
}
function _formatStatusTokens(s){
const lastUsage=(typeof S!=='undefined'&&(S.lastUsage||s.last_usage))||{};
const input=Number(s.input_tokens??lastUsage.input_tokens??0)||0;
const output=Number(s.output_tokens??lastUsage.output_tokens??0)||0;
const total=Number(s.total_tokens??lastUsage.total_tokens??(input+output))||0;
const cost=Number(s.estimated_cost??lastUsage.estimated_cost??0)||0;
if(!total&&!cost) return t('status_no_tokens');
const fmtNum=n=>Number(n||0).toLocaleString();
return `${fmtNum(input)} in / ${fmtNum(output)} out${cost?` (~$${cost.toFixed(4)})`:''}`;
}
function _statusProviderForSession(s){
if(s.model_provider) return String(s.model_provider);
if(window._activeProvider) return String(window._activeProvider);
const model=String(s.model||'');
return model.includes('/') ? model.split('/')[0] : '';
}
function _statusCardFromSession(s){
const provider=_statusProviderForSession(s);
const model=s.model||(($('modelSelect')&&$('modelSelect').value)||t('usage_default_model'));
const running=!!(s.active_stream_id||S.activeStreamId||S.busy);
const profile=s.profile||S.activeProfile||'default';
const workspace=s.workspace||S.currentDir||t('status_unknown');
const rows=[
{label:t('status_session_id'), value:s.session_id||t('status_unknown')},
{label:t('status_title'), value:s.title||t('untitled')},
{label:t('status_model'), value:model},
{label:t('status_provider'), value:provider||t('status_unknown')},
{label:t('status_profile'), value:profile},
{label:t('status_workspace'), value:workspace},
{label:t('status_personality'), value:s.personality||t('usage_personality_none')},
{label:t('status_started'), value:_formatStatusTimestamp(s.created_at)},
{label:t('status_updated'), value:_formatStatusTimestamp(s.updated_at||s.last_message_at)},
{label:t('status_tokens'), value:_formatStatusTokens(s)},
{label:t('status_messages'), value:String(s.message_count??(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length)},
{label:t('status_agent_running'), value:running?t('status_yes'):t('status_no')},
];
return {
title:t('status_heading'),
subtitle:t('status_ephemeral'),
sessionId:s.session_id||'',
rows,
};
}
function cmdStatus(){
if(!S.session){showToast(t('no_active_session'));return;}
2026-05-04 23:58:54 +00:00
S.messages.push({
role:'assistant',
content:'',
_ephemeral:true,
_statusCard:_statusCardFromSession(S.session),
_ts:Date.now()/1000,
});
renderMessages();
}
function cmdReasoning(args){
const arg=(args||'').trim().toLowerCase();
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
// Keep this WebUI effort list in sync with hermes-agent#29248.
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
const q=(typeof _reasoningEffortQuery==='function')?_reasoningEffortQuery():'';
api('/api/reasoning'+q).then(function(st){showToast(_fmtStatus(st));})
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
return true;
}
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
const on=(arg==='show'||arg==='on');
// Update the UI render gate immediately for responsiveness.
window._showThinking=on;
if(!on&&typeof removeThinking==='function') removeThinking();
if(typeof renderMessages==='function') renderMessages();
// Persist via /api/reasoning → config.yaml display.show_reasoning
// (CLI reads the same key). Also mirror into WebUI settings.json
// show_thinking so boot.js picks it up on reload without hitting
// /api/reasoning on every page load.
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
return true;
}
if(EFFORTS.includes(arg)){
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff, st||{});
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
});
return true;
}
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
return true;
}
function cmdVoice(){
const mic=document.getElementById('btnMic');
const micVisible=!!(
mic
&& mic.style.display!=='none'
&& !mic.disabled
&& !mic.classList.contains('composer-control-hidden')
&& mic.getAttribute('aria-hidden')!=='true'
&& (!window.getComputedStyle||window.getComputedStyle(mic).display!=='none')
);
if(micVisible){try{mic.click();return;}catch(_){}}
showToast(t('cmd_voice_use_mic'));
}
// ── YOLO mode toggle ──
// Session-scoped: skips all approval prompts for the current session.
// Toggles on/off; state is not persisted across page reloads.
async function cmdYolo(){
const sid=S.session&&S.session.session_id;
if(!sid){showToast(t('yolo_no_session'));return;}
try{
// Check current state first to toggle
const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid));
const enable=!status.yolo_enabled;
await api('/api/session/yolo',{
method:'POST',
body:JSON.stringify({session_id:sid,enabled:enable}),
});
_yoloEnabled=enable;
_updateYoloPill();
showToast(enable?t('yolo_enabled'):t('yolo_disabled'));
if(enable){
// Dismiss any visible approval card
hideApprovalCard(true);
}
}catch(e){showToast('YOLO: '+e.message);}
}
// ── Branch / fork command ──
// Forks the current conversation into a new session (#465).
// /branch → full history copy
// /branch My Name → full history copy with custom title
async function cmdBranch(args){
if(!S.session){showToast(t('no_active_session'));return;}
const readOnlySession=typeof _isReadOnlySession==='function'
? _isReadOnlySession(S.session)
: !!(S.session&&(S.session.read_only||S.session.is_read_only));
const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'
? _isBranchableReadOnlySession(S.session)
: false;
if(readOnlySession&&!branchableReadOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const customTitle=(args||'').trim()||null;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:S.session.session_id,
title:customTitle||undefined,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
// ── Fork from a specific message point ──
// Called from the "Fork from here" button on message hover actions.
// msgIdx is 1-based within the currently loaded tail window (rawIdx+1).
// When the session is truncated (_oldestIdx > 0), msgIdx alone would be
// a local-window count, but the backend expects an absolute message count
// from the beginning of the full transcript. We capture the absolute
// count (_oldestIdx + msgIdx) BEFORE awaiting _ensureAllMessagesLoaded,
// which resets _oldestIdx to 0 after its wholesale replace. See #2184.
async function forkFromMessage(msgIdx){
if(!S.session||S.busy)return;
const readOnlySession=typeof _isReadOnlySession==='function'
? _isReadOnlySession(S.session)
: !!(S.session&&(S.session.read_only||S.session.is_read_only));
const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'
? _isBranchableReadOnlySession(S.session)
: false;
if(readOnlySession&&!branchableReadOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const initialSid = S.session.session_id;
// Capture the absolute keep_count before any async work that may
// reset _oldestIdx. _oldestIdx is 0 when the full transcript is
// already loaded, so short/already-full sessions send msgIdx unchanged.
const absoluteKeepCount = _oldestIdx + msgIdx;
// Ensure the full transcript is loaded so the forked session renders
// correctly and subsequent operations see the complete history.
if(typeof _ensureAllMessagesLoaded==='function'){
await _ensureAllMessagesLoaded();
}
if(!S.session || S.session.session_id !== initialSid) return;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:initialSid,
keep_count:absoluteKeepCount,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
let _skillCommandCache=[];
let _skillCommandLoadPromise=null;
let _skillCommandCacheReady=false;
function _skillCommandSlug(name){
const raw=String(name||'').trim().toLowerCase();
if(!raw)return'';
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
}
function _getReservedSlashCommandSlugs(){
const reserved=new Set(COMMANDS.map(c=>String(c&&c.name||'').trim().toLowerCase()).filter(Boolean));
for(const cmd of (_agentCommandCache||[])){
const names=[cmd.name].concat(Array.isArray(cmd&&cmd.aliases)?cmd.aliases:[]);
for(const name of names){
const slug=_skillCommandSlug(name);
if(slug) reserved.add(slug);
}
}
return reserved;
}
function _buildSkillCommandEntry(skill){
const skillName=String(skill&&skill.name||'').trim();
const slug=_skillCommandSlug(skillName);
if(!slug)return null;
if(_getReservedSlashCommandSlugs().has(slug)) return null;
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
}
function _buildBundleCommandEntry(bundle){
const slug=_skillCommandSlug(bundle&&bundle.name);
if(!slug)return null;
if(_getReservedSlashCommandSlugs().has(slug)) return null;
const skillCount=Number(bundle&&bundle.skill_count||0);
return{
name:slug,
desc:String(bundle&&bundle.description||'').trim()||'Skill bundle',
source:'bundle',
skillCount:Number.isFinite(skillCount)?skillCount:0,
};
}
async function loadSkillCommands(force=false){
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
_skillCommandLoadPromise=(async()=>{
try{
const data=await api('/api/skills');
const deduped=new Map();
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
}catch(_){_skillCommandCache=[];}
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
return _skillCommandCache;
})();
return _skillCommandLoadPromise;
}
async function loadBundleCommands(force=false){
if(_bundleCommandCacheReady&&!force)return _bundleCommandCache;
if(_bundleCommandLoadPromise&&!force)return _bundleCommandLoadPromise;
_bundleCommandLoadPromise=(async()=>{
try{
await loadAgentCommandMetadata();
const data=await api('/api/commands/bundles');
const deduped=new Map();
for(const bundle of (data&&data.bundles)||[]){const entry=_buildBundleCommandEntry(bundle);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
_bundleCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
}catch(_){_bundleCommandCache=[];}
finally{_bundleCommandCacheReady=true;_bundleCommandLoadPromise=null;}
return _bundleCommandCache;
})();
return _bundleCommandLoadPromise;
}
async function getBundleCommandMetadata(name){
const needle=String(name||'').trim().toLowerCase();
if(!needle) return null;
const bundles=await loadBundleCommands();
return bundles.find(bundle=>String(bundle&&bundle.name||'').toLowerCase()===needle)||null;
}
function refreshSlashCommandDropdown(){
const ta=$('msg');if(!ta)return;
const text=ta.value||'';
if(text.indexOf('\n')!==-1||_activeSlashCommandOffset(text)<0){hideCmdDropdown();return;}
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
});
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){
loadBundleCommands().then(()=>{refreshSlashCommandDropdown();});
}
// Also preload agent/plugin command metadata for autocomplete
if(!_agentCommandCacheReady&&!_agentCommandCachePromise){
loadAgentCommandMetadata().then(()=>{refreshSlashCommandDropdown();});
}
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;
function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=matches.length?0:-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
if(i===_cmdSelectedIdx) el.classList.add('selected');
el.dataset.idx=i;
const isSubArg=c.source==='subarg';
const isPath=c.source==='path';
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
const badge=c.source==='skill'
? ` <span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`
: c.source==='bundle'
? ' <span class="cmd-item-badge">Bundle</span>'
: '';
if(c.source==='skill') el.classList.add('cmd-item-skill');
if(isPath) el.classList.add('cmd-item-path');
const nameHtml=isPath
? `<div class="cmd-item-name"><span class="cmd-item-path-value">${esc(c.value)}</span></div>`
: isSubArg
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.innerHTML=`${nameHtml}${descHtml}`;
el.onmousedown=(e)=>{
e.preventDefault();
if(isPath){
const ta=$('msg');
if(!ta){hideCmdDropdown();return;}
const start=Number.isFinite(Number(c.tokenStart))?Number(c.tokenStart):ta.selectionStart;
const end=Number.isFinite(Number(c.tokenEnd))?Number(c.tokenEnd):ta.selectionEnd;
const nextPath=String(c.value||'').endsWith('/')?String(c.value||''):`${String(c.value||'')}/`;
const current=String(ta.value||'');
const safeStart=Math.max(0,Math.min(start,current.length));
const safeEnd=Math.max(safeStart,Math.min(end,current.length));
ta.value=current.slice(0,safeStart)+nextPath+current.slice(safeEnd);
const pos=safeStart+nextPath.length;
ta.focus();
ta.setSelectionRange(pos,pos);
ta.dispatchEvent(new Event('input',{bubbles:true}));
hideCmdDropdown();
return;
}
const _ta=$('msg');
const _cur=String(_ta&&_ta.value||'');
const _slashIdx=_activeSlashCommandOffset(_cur);
const _prefix=_slashIdx>=0?_cur.slice(0,_slashIdx):'';
const nextValue=_prefix+(isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':'')));
if(_ta)_ta.value=nextValue;
if(_ta)_ta.focus();
if(!isSubArg&&c.source!=='skill'&&c.source!=='bundle'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(nextValue).then(matches=>{
if(($('msg').value||'')!==nextValue) return;
if(matches.length) showCmdDropdown(matches);
else hideCmdDropdown();
});
}else{
hideCmdDropdown();
}
};
dd.appendChild(el);
}
dd.classList.add('open');
}
function hideCmdDropdown(){
const dd=$('cmdDropdown');
if(dd)dd.classList.remove('open');
_cmdSelectedIdx=-1;
}
function navigateCmdDropdown(dir){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(!items.length)return;
items.forEach(el=>el.classList.remove('selected'));
_cmdSelectedIdx+=dir;
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected');
// Scroll the newly highlighted item into view so it stays visible when the
// dropdown overflows and the user navigates with keyboard (#838).
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
}
function selectCmdDropdownItem(){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
} else if(items.length===1){
items[0].onmousedown({preventDefault:()=>{}});
}
hideCmdDropdown();
}
// ── Handler aliases (for test-discoverable command registration) ──────────────
// The COMMANDS array above is the authoritative dispatch table. These aliases
// allow tooling and tests to discover command handlers by name independently.
const HANDLERS = {};
HANDLERS.skills = cmdSkills;