feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
// ── 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).
2026-04-08 14:55:03 +00:00
{ 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 } ,
2026-04-28 01:42:53 +00:00
{ 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 } ,
2026-04-20 22:04:28 +00:00
{ name : 'personality' , desc : t ( 'cmd_personality' ) , fn : cmdPersonality , arg : 'name' , subArgs : 'personalities' } ,
2026-04-19 05:37:44 +00:00
{ name : 'skills' , desc : t ( 'cmd_skills' ) , fn : cmdSkills , arg : 'query' } ,
2026-06-05 18:35:26 +00:00
{ 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 } ,
2026-04-19 05:37:44 +00:00
{ 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 } ,
2026-04-24 01:24:51 +00:00
{ name : 'btw' , desc : t ( 'cmd_btw' ) , fn : cmdBtw , arg : 'question' , noEcho : true } ,
{ name : 'background' , desc : t ( 'cmd_background' ) , fn : cmdBackground , arg : 'prompt' , noEcho : true } ,
2026-04-19 05:37:44 +00:00
{ 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 } ,
2026-06-13 17:59:55 +00:00
{ 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 } ,
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
{ name : 'yolo' , desc : t ( 'cmd_yolo' ) , fn : cmdYolo , noEcho : true } ,
2026-05-01 05:32:45 +00:00
{ name : 'branch' , desc : t ( 'cmd_branch' ) , fn : cmdBranch , arg : '[name]' , noEcho : true } ,
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
] ;
2026-04-20 22:04:28 +00:00
const SLASH _SUBARG _SOURCES = {
model : { desc : t ( 'cmd_model' ) , subArgs : 'models' } ,
personality : { desc : t ( 'cmd_personality' ) , subArgs : 'personalities' } ,
} ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
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 } ;
}
2026-06-28 23:32:42 +00:00
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 . ` ;
}
2026-06-28 23:32:42 +00:00
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 . ` ;
}
2026-06-28 23:32:42 +00:00
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 ;
2026-06-28 23:32:42 +00:00
try {
status = await api ( '/api/extensions/status' ) ;
} catch ( _e ) {
2026-06-29 00:24:29 +00:00
return { handled : false , message : _desktopCompanionStatusUnavailableMessage ( ) } ;
2026-06-28 23:32:42 +00:00
}
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 } ,
} ) ;
2026-06-28 23:56:05 +00:00
if ( result ) {
2026-06-29 00:24:29 +00:00
return {
handled : true ,
message : result && typeof result === 'object' && 'message' in result
2026-06-29 22:26:40 +00:00
? 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-28 23:32:42 +00:00
}
2026-06-29 00:24:29 +00:00
return { handled : false , message : _desktopCompanionHookErrorMessage ( ) } ;
}
2026-06-28 23:32:42 +00:00
}
return { handled : false , message : _desktopCompanionUnavailableMessage ( ) } ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
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 ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
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 ;
2026-04-21 22:26:52 +00:00
// 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 } ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
function getMatchingCommands ( prefix ) {
const q = prefix . toLowerCase ( ) ;
2026-04-19 05:37:44 +00:00
const matches = COMMANDS . filter ( c => c . name . startsWith ( q ) ) . map ( c => ( { ... c , source : 'builtin' } ) ) ;
const seen = new Set ( matches . map ( c => c . name ) ) ;
2026-06-12 00:58:51 +00:00
const reserved = _getReservedSlashCommandSlugs ( ) ;
2026-04-20 22:04:28 +00:00
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 ) ;
}
2026-06-28 23:32:42 +00:00
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' ) ;
}
2026-05-09 21:14:21 +00:00
// 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 ;
2026-06-28 23:32:42 +00:00
if ( cmd . cli _only && name !== 'pet' ) continue ;
2026-05-09 21:14:21 +00:00
matches . push ( {
name ,
desc : String ( cmd && cmd . description || '' ) . trim ( ) || 'Agent command' ,
source : cmd . category === 'Plugin' ? 'plugin' : 'agent' ,
} ) ;
seen . add ( name ) ;
}
2026-06-13 22:53:46 +00:00
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 ) ;
}
2026-06-13 17:47:15 +00:00
}
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 ) ;
}
2026-04-19 05:37:44 +00:00
return matches ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
2026-06-05 18:35:26 +00:00
let _forcedSkillDirectivePending = null ;
2026-04-20 22:04:28 +00:00
let _slashModelCache = null ;
let _slashModelCachePromise = null ;
let _slashPersonalityCache = null ;
let _slashPersonalityCachePromise = null ;
2026-06-13 11:46:21 +00:00
let _bundleCommandCache = [ ] ;
let _bundleCommandLoadPromise = null ;
let _bundleCommandCacheReady = false ;
2026-06-11 20:30:23 +00:00
let _slashSkillCache = null ;
let _slashSkillCachePromise = null ;
2026-05-01 04:46:30 +00:00
let _agentCommandCache = null ;
let _agentCommandCachePromise = null ;
2026-04-20 22:04:28 +00:00
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 ;
2026-06-11 20:30:23 +00:00
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
}
2026-04-20 22:04:28 +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 ) ;
}
2026-04-20 22:04:28 +00:00
}
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 ;
}
2026-06-11 20:30:23 +00:00
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 ;
}
2026-04-20 22:04:28 +00:00
function _getSlashSubArgOptions ( spec ) {
if ( Array . isArray ( spec ) ) return Promise . resolve ( spec . slice ( ) ) ;
if ( spec === 'models' ) return _loadSlashModelSubArgs ( ) ;
if ( spec === 'personalities' ) return _loadSlashPersonalitySubArgs ( ) ;
2026-06-11 20:30:23 +00:00
if ( spec === 'skills' ) return _loadSlashSkillSubArgs ( ) ;
2026-04-20 22:04:28 +00:00
return Promise . resolve ( [ ] ) ;
}
2026-05-09 21:14:21 +00:00
let _agentCommandCacheReady = false ;
2026-05-01 04:46:30 +00:00
async function loadAgentCommandMetadata ( force = false ) {
2026-05-09 21:14:21 +00:00
if ( _agentCommandCacheReady && ! force ) return _agentCommandCache || [ ] ;
if ( _agentCommandCachePromise && ! force ) return _agentCommandCachePromise ;
2026-05-01 04:46:30 +00:00
_agentCommandCachePromise = ( async ( ) => {
try {
const data = await api ( '/api/commands' ) ;
_agentCommandCache = Array . isArray ( data && data . commands ) ? data . commands : [ ] ;
} catch ( _ ) {
_agentCommandCache = [ ] ;
} finally {
2026-05-09 21:14:21 +00:00
_agentCommandCacheReady = true ;
2026-05-01 04:46:30 +00:00
_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 } ` ;
}
2026-06-04 22:21:41 +00:00
async function executeAgentCommand ( text , _meta ) {
return _runAgentCommandTransport ( text , _meta ) ;
}
2026-05-09 21:14:21 +00:00
async function executeAgentPluginCommand ( text , _meta ) {
2026-06-04 22:21:41 +00:00
return _runAgentCommandTransport ( text , _meta ) ;
}
async function _runAgentCommandTransport ( text , _meta ) {
2026-05-09 21:14:21 +00:00
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)' ) ;
}
2026-06-13 11:46:21 +00:00
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 } )
} ) ;
}
2026-06-14 18:54:18 +00:00
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 ;
}
2026-04-20 22:04:28 +00:00
function _parseSlashAutocomplete ( text ) {
2026-06-14 18:54:18 +00:00
const slashIdx = _activeSlashCommandOffset ( text ) ;
if ( slashIdx < 0 ) return null ;
const raw = text . slice ( slashIdx + 1 ) ;
2026-04-20 22:04:28 +00:00
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 ,
} ) ) ;
}
2026-06-04 04:12:23 +00:00
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 ,
} ) ) ;
}
2026-04-18 06:55:04 +00:00
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 } ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
// ── Command handlers ────────────────────────────────────────────────────────
function cmdHelp ( ) {
const lines = COMMANDS . map ( c => {
2026-04-18 06:55:04 +00:00
const usage = c . arg ? ( String ( c . arg ) . startsWith ( '[' ) ? ` ${ c . arg } ` : ` < ${ c . arg } > ` ) : '' ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
return ` / ${ c . name } ${ usage } — ${ c . desc } ` ;
} ) ;
2026-04-08 14:55:03 +00:00
const msg = { role : 'assistant' , content : t ( 'available_commands' ) + '\n' + lines . join ( '\n' ) } ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
S . messages . push ( msg ) ;
renderMessages ( ) ;
2026-04-08 14:55:03 +00:00
showToast ( t ( 'type_slash' ) ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
function cmdClear ( ) {
if ( ! S . session ) return ;
S . messages = [ ] ; S . toolCalls = [ ] ;
clearLiveToolCards ( ) ;
2026-04-18 06:55:04 +00:00
if ( typeof clearCompressionUi === 'function' ) clearCompressionUi ( ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
renderMessages ( ) ;
$ ( 'emptyState' ) . style . display = '' ;
2026-04-08 14:55:03 +00:00
showToast ( t ( 'conversation_cleared' ) ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
2026-06-02 08:57:55 +00:00
// 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.
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 || '' ) ) ;
}
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 } ;
}
2026-06-02 08:57:55 +00:00
function _bestModelMatch ( options , query ) {
let best = null ;
2026-06-03 04:35:09 +00:00
const versioned = _looksLikeVersionedModel ( query ) ;
2026-06-02 08:57:55 +00:00
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 ) ) {
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 ;
}
2026-06-02 08:57:55 +00:00
if ( best === null || opt . value . length < best . length ) best = opt . value ;
}
}
return best ;
}
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 ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
async function cmdModel ( args ) {
2026-04-08 14:55:03 +00:00
if ( ! args ) { showToast ( t ( 'model_usage' ) ) ; return ; }
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
const sel = $ ( 'modelSelect' ) ;
if ( ! sel ) return ;
2026-05-17 19:22:06 +00:00
let q = args . toLowerCase ( ) ;
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 ;
2026-05-17 19:22:06 +00:00
try {
2026-06-18 21:44:43 +00:00
const resp = await fetch ( new URL ( 'api/models' , document . baseURI || location . href ) . href ) ;
2026-05-17 19:22:06 +00:00
if ( resp . ok ) {
2026-06-03 16:13:01 +00:00
modelsData = await resp . json ( ) ;
const aliases = modelsData . aliases || { } ;
2026-05-17 19:22:06 +00:00
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"
2026-05-17 19:22:06 +00:00
break ;
}
}
}
} catch ( _ ) { /* non-critical, fall through to fuzzy match */ }
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 ;
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 ) {
2026-06-03 16:13:01 +00:00
match = _bestModelMatch ( candidates , q ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
2026-05-17 19:22:06 +00:00
// 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 ) ;
2026-06-03 16:13:01 +00:00
match = _bestModelMatch ( candidates , bare ) ;
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.
2026-06-03 16:13:01 +00:00
const nearSuggestion = _nearestModelSuggestion ( candidates , q ) || _nearestModelSuggestion ( candidates , bare ) ;
2026-06-03 04:35:09 +00:00
const versionedNoSnap = _looksLikeVersionedModel ( bare ) && nearSuggestion ;
2026-05-17 19:22:06 +00:00
// Cross-provider fallback: if still no match, the model is from a
// different provider not in the dropdown. Call /api/session/update directly.
2026-06-03 04:35:09 +00:00
if ( ! match && ! versionedNoSnap && S && S . session && S . session . session _id ) {
2026-05-17 19:22:06 +00:00
const provider = q . slice ( 0 , q . indexOf ( '/' ) ) ;
try {
2026-06-18 21:44:43 +00:00
const resp = await fetch ( new URL ( 'api/session/update' , document . baseURI || location . href ) . href , {
2026-05-17 19:22:06 +00:00
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" */ }
}
}
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 } " ` ;
2026-06-03 16:13:01 +00:00
const suggestion = _nearestModelSuggestion ( candidates , q ) ;
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 ;
}
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 ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
await sel . onchange ( ) ;
2026-04-08 14:55:03 +00:00
showToast ( t ( 'switched_to' ) + match ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
async function cmdWorkspace ( args ) {
2026-04-08 14:55:03 +00:00
if ( ! args ) { showToast ( t ( 'workspace_usage' ) ) ; return ; }
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
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 )
) ;
2026-04-08 14:55:03 +00:00
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 ) ) ;
2026-04-08 14:55:03 +00:00
} catch ( e ) { showToast ( t ( 'workspace_switch_failed' ) + e . message ) ; }
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
2026-04-28 01:42:53 +00:00
async function cmdTerminal ( ) {
2026-06-06 21:17:17 +00:00
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 ( _ ) { }
2026-04-28 01:42:53 +00:00
if ( ! S . session && typeof newSession === 'function' ) {
if ( ! S . _profileSwitchWorkspace && ! S . _profileDefaultWorkspace ) {
2026-06-06 21:17:17 +00:00
const first = ( data && data . workspaces || [ ] ) [ 0 ] ;
S . _profileSwitchWorkspace = ( data && data . last ) || ( first && first . path ) || null ;
2026-04-28 01:42:53 +00:00
}
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 ) ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
async function cmdNew ( ) {
2026-04-18 06:55:04 +00:00
if ( typeof clearCompressionUi === 'function' ) clearCompressionUi ( ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
await newSession ( ) ;
await renderSessionList ( ) ;
$ ( 'msg' ) . focus ( ) ;
2026-04-08 14:55:03 +00:00
showToast ( t ( 'new_session' ) ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
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 ) {
2026-05-13 09:52:42 +00:00
// 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 ( '' ) ;
}
}
}
2026-04-18 06:55:04 +00:00
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 || [ ] ;
2026-04-27 23:27:03 +00:00
if ( typeof _messagesTruncated !== 'undefined' ) _messagesTruncated = false ;
2026-04-18 06:55:04 +00:00
} 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 ( ) ;
2026-04-18 06:55:04 +00:00
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-04-18 06:55:04 +00:00
}
2026-05-12 09:32:05 +00:00
const data = ( started && started . status === 'done' ) ? started : await _pollManualCompressionResult ( sid ) ;
await _applyManualCompressionResult ( data , focusTopic , visibleCount , commandText ) ;
2026-04-18 06:55:04 +00:00
} 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 ( ) ) ;
2026-04-05 01:46:34 +00:00
}
2026-04-04 02:04:41 +00:00
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 ( ) ;
2026-04-08 14:55:03 +00:00
showToast ( next ? t ( 'token_usage_on' ) : t ( 'token_usage_off' ) ) ;
2026-04-04 02:04:41 +00:00
}
feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference,
and zero-flicker loading. Custom themes are pure CSS additions.
Theme system:
- CSS variable overrides via :root[data-theme="name"] blocks
- Flicker prevention: inline <script> reads localStorage before
stylesheet parses, preventing dark-flash on light-mode users
- Server-side persistence via settings.json (theme field)
- Boot.js syncs server preference to DOM + localStorage
Built-in themes:
- Dark (default): deep navy/indigo, muted blue accents
- Light: clean white/gray, high contrast, scrollbar overrides
- Solarized Dark: teal background, warm accents
- Monokai: warm dark, green/pink accents
- Nord: arctic blue-gray, calm and minimal
UI integration:
- Settings panel: theme dropdown with instant live preview
- /theme slash command: /theme dark|light|solarized|monokai|nord
- No enum constraint on theme setting — custom themes just work
Documentation:
- THEMES.md: how to switch themes, create custom themes, contribute
8 new tests. All 408 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:48:05 +00:00
async function cmdTheme ( args ) {
2026-04-18 06:37:09 +00:00
const themes = [ 'system' , 'dark' , 'light' ] ;
2026-05-19 06:25:34 +00:00
const skins = ( _SKINS || [ ] ) . map ( s => ( s . value || s . name ) . toLowerCase ( ) ) ;
2026-04-18 06:37:09 +00:00
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 ) ;
feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference,
and zero-flicker loading. Custom themes are pure CSS additions.
Theme system:
- CSS variable overrides via :root[data-theme="name"] blocks
- Flicker prevention: inline <script> reads localStorage before
stylesheet parses, preventing dark-flash on light-mode users
- Server-side persistence via settings.json (theme field)
- Boot.js syncs server preference to DOM + localStorage
Built-in themes:
- Dark (default): deep navy/indigo, muted blue accents
- Light: clean white/gray, high contrast, scrollbar overrides
- Solarized Dark: teal background, warm accents
- Monokai: warm dark, green/pink accents
- Nord: arctic blue-gray, calm and minimal
UI integration:
- Settings panel: theme dropdown with instant live preview
- /theme slash command: /theme dark|light|solarized|monokai|nord
- No enum constraint on theme setting — custom themes just work
Documentation:
- THEMES.md: how to switch themes, create custom themes, contribute
8 new tests. All 408 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:48:05 +00:00
return ;
}
2026-04-18 06:37:09 +00:00
showToast ( t ( 'theme_usage' ) + themes . join ( '|' ) + ' | ' + skins . join ( '|' ) + ' | legacy:' + legacyThemes . join ( '|' ) ) ;
feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference,
and zero-flicker loading. Custom themes are pure CSS additions.
Theme system:
- CSS variable overrides via :root[data-theme="name"] blocks
- Flicker prevention: inline <script> reads localStorage before
stylesheet parses, preventing dark-flash on light-mode users
- Server-side persistence via settings.json (theme field)
- Boot.js syncs server preference to DOM + localStorage
Built-in themes:
- Dark (default): deep navy/indigo, muted blue accents
- Light: clean white/gray, high contrast, scrollbar overrides
- Solarized Dark: teal background, warm accents
- Monokai: warm dark, green/pink accents
- Nord: arctic blue-gray, calm and minimal
UI integration:
- Settings panel: theme dropdown with instant live preview
- /theme slash command: /theme dark|light|solarized|monokai|nord
- No enum constraint on theme setting — custom themes just work
Documentation:
- THEMES.md: how to switch themes, create custom themes, contribute
8 new tests. All 408 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:48:05 +00:00
}
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 ) ;
}
}
2026-06-05 18:35:26 +00:00
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 ;
}
2026-06-12 12:34:27 +00:00
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. ` ) ;
2026-06-12 12:49:56 +00:00
const directive = ` [USER OVERRIDE] You MUST follow the skill ' ${ match . name } ' content provided below before responding to the next message. ` ;
2026-06-12 12:34:27 +00:00
resolve ( { name : match . name , directive , content : skillContent } ) ;
2026-06-05 18:35:26 +00:00
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 ) {
2026-04-08 14:55:03 +00:00
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 ) {
2026-04-08 14:55:03 +00:00
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' ) ;
2026-04-08 14:55:03 +00:00
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 ( ) ;
2026-04-08 14:55:03 +00:00
} 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 : '' } ) } ) ;
2026-04-08 14:55:03 +00:00
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 ( ) ;
2026-04-08 14:55:03 +00:00
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
}
2026-04-19 05:37:44 +00:00
async function cmdStop ( ) {
if ( ! S . session ) { showToast ( t ( 'no_active_session' ) ) ; return ; }
if ( ! S . activeStreamId ) { showToast ( t ( 'no_active_task' ) ) ; return ; }
2026-07-01 15:25:49 +00:00
if ( typeof cancelStream === 'function' ) { await cancelStream ( 'slash-stop' ) ; showToast ( t ( 'stream_stopped' ) ) ; }
2026-04-19 05:37:44 +00:00
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' ,
} ) } ) ;
2026-05-10 07:21:24 +00:00
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 ) ;
2026-05-10 07:21:24 +00:00
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 ──────────────────────────────────────────────
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.
/ * *
* / q u e u e < m e s s a g e > — E x p l i c i t l y q u e u e a m e s s a g e f o r t h e n e x t t u r n .
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 ; }
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 ) ;
}
/ * *
* / i n t e r r u p t < m e s s a g e > — C a n c e l t h e c u r r e n t t u r n a n d s e n d a n e w m e s s a g e .
* 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 ; }
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
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 ) ;
}
/ * *
2026-04-26 02:21:00 +00:00
* / s t e e r < m e s s a g e > — I n j e c t a s t e e r i n g h i n t m i d - t a s k w i t h o u t i n t e r r u p t i n g .
*
* 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 .
*
2026-06-22 06:22:51 +00:00
* 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 ( ) ;
2026-07-05 06:50:34 +00:00
const hasPendingFiles = typeof S !== 'undefined' && Array . isArray ( S . pendingFiles ) && S . pendingFiles . length > 0 ;
if ( ! msg && ! hasPendingFiles ) { showToast ( t ( 'cmd_steer_no_msg' ) ) ; return ; }
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 ; }
2026-04-26 02:21:00 +00:00
await _trySteer ( msg , /*explicitSteer=*/ true ) ;
}
2026-06-24 13:04:21 +00:00
function _steerFailureMessageKey ( fallback ) {
2026-07-09 17:04:21 +00:00
if ( fallback === 'gateway_steer_queued' ) return 'steer_fail_no_cached_agent' ;
2026-06-24 13:04:21 +00:00
const key = 'steer_fail_' + ( fallback || 'unknown' ) ;
return ( typeof LOCALES !== 'undefined' && LOCALES . en && LOCALES . en [ key ] )
? key : 'steer_fail_unknown' ;
}
2026-05-13 12:30:44 +00:00
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 ( ) ;
}
2026-06-24 13:04:21 +00:00
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' ;
2026-07-07 16:32:26 +00:00
retryBtn . textContent = t ( _steerFallbackIsDeadRun ( fallback ) ? 'clarify_send' : 'steer_recovery_retry' ) ;
2026-06-24 13:04:21 +00:00
retryBtn . addEventListener ( 'click' , ( ) => {
el . remove ( ) ;
2026-07-07 16:32:26 +00:00
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 ) ;
}
2026-06-24 13:04:21 +00:00
} ) ;
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 ( ) ;
}
2026-06-24 13:23:17 +00:00
/ * *
2026-07-02 05:01:27 +00:00
* Shared implementation for / s t e e r a n d t h e d e f a u l t _ m e s s a g e _ m o d e = ' s t e e r ' p a t h .
2026-06-24 13:23:17 +00:00
*
* 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
2026-06-24 14:17:04 +00:00
* ( vs the busy - mode auto - fallback ) . Affects draft restore prefix only ;
* toast wording is determined by the failure reason code .
2026-06-24 13:23:17 +00:00
* @ returns { Promise < boolean > } true when the steer was delivered , false when the
* draft was restored and the active stream was left untouched .
* /
2026-07-05 06:50:34 +00:00
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 ) ;
}
2026-07-07 16:32:26 +00:00
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 ;
}
2026-07-05 09:25:21 +00:00
function _steerSetComposerStatusForOwner ( ownerSid , text ) {
if ( _steerOwnerIsCurrent ( ownerSid ) && typeof setComposerStatus === 'function' ) setComposerStatus ( text ) ;
}
2026-07-05 06:50:34 +00:00
function _steerRestoreText ( originalMsg , explicitSteer ) {
return explicitSteer ? ` /steer ${ originalMsg } ` : originalMsg ;
}
2026-07-05 09:25:21 +00:00
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' ;
}
2026-07-05 06:50:34 +00:00
async function _steerPersistDraftForOwner ( ownerSid , originalMsg , explicitSteer , filesSnapshot ) {
if ( ! ownerSid || typeof _saveComposerDraftNow !== 'function' ) return ;
await _saveComposerDraftNow ( ownerSid , _steerRestoreText ( originalMsg , explicitSteer ) , filesSnapshot ) ;
}
2026-07-05 07:10:23 +00:00
// #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 ( ) ) ; }
}
2026-07-05 06:50:34 +00:00
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 ;
2026-07-05 07:10:23 +00:00
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 {
2026-07-05 09:25:21 +00:00
_steerSetComposerStatusForOwner ( ownerSid , t ( 'uploading' ) || 'Uploading…' ) ;
2026-07-05 07:10:23 +00:00
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 {
2026-07-05 09:25:21 +00:00
_steerSetComposerStatusForOwner ( ownerSid , '' ) ;
2026-07-05 07:10:23 +00:00
}
paths = _steerUploadedAttachmentPaths ( uploaded ) ;
if ( paths . length ) _steerUploadCache = { sid : ownerSid , sig , paths } ;
2026-07-05 06:50:34 +00:00
}
2026-07-05 07:10:23 +00:00
if ( ! paths || ! paths . length ) return base ;
2026-07-05 06:50:34 +00:00
const note = ` [Attached files for this steer: ${ paths . join ( ', ' ) } ] \n Use the file tools/read_file to inspect these documents if needed. ` ;
return base ? ` ${ base } \n \n ${ note } ` : note ;
}
2026-04-26 02:21:00 +00:00
async function _trySteer ( msg , explicitSteer ) {
let result = null ;
2026-07-05 06:50:34 +00:00
const originalMsg = String ( msg || '' ) . trim ( ) ;
const ownerSid = ( typeof S !== 'undefined' && S . session && S . session . session _id ) || null ;
2026-07-07 16:32:26 +00:00
const ownerStreamId = ( typeof S !== 'undefined' && ( S . activeStreamId || ( S . session && S . session . active _stream _id ) ) ) || null ;
2026-07-05 06:50:34 +00:00
const pendingFilesSnapshot = typeof S !== 'undefined' && Array . isArray ( S . pendingFiles ) ? [ ... S . pendingFiles ] : [ ] ;
2026-07-09 17:04:21 +00:00
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 ) || '' } ;
2026-07-05 06:50:34 +00:00
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 ) ;
}
2026-07-05 09:25:21 +00:00
_steerSetComposerStatusForOwner ( ownerSid , '' ) ;
2026-07-05 06:50:34 +00:00
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 ;
}
2026-04-26 02:21:00 +00:00
try {
result = await api ( '/api/chat/steer' , {
method : 'POST' ,
2026-07-05 06:50:34 +00:00
body : JSON . stringify ( { session _id : ownerSid , text : steerText } ) ,
2026-04-26 02:21:00 +00:00
} ) ;
} catch ( e ) {
2026-06-22 06:22:51 +00:00
// Network or server error — keep the active stream running and restore the draft.
2026-04-26 02:21:00 +00:00
result = { accepted : false , fallback : 'network_error' } ;
}
if ( result && result . accepted ) {
2026-07-05 07:10:23 +00:00
// 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
2026-07-05 06:50:34 +00:00
if ( ownerSid && typeof _clearComposerDraft === 'function' ) _clearComposerDraft ( ownerSid , _steerRestoreText ( originalMsg , explicitSteer ) , pendingFilesSnapshot ) ;
2026-05-13 12:30:44 +00:00
// 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
2026-07-05 06:50:34 +00:00
// 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 ) ) {
2026-07-05 07:10:23 +00:00
// 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 ( ) ; }
}
2026-07-05 09:25:21 +00:00
_showSteerIndicator ( _steerIndicatorText ( originalMsg , pendingFilesSnapshot ) ) ;
2026-07-05 06:50:34 +00:00
}
2026-04-26 02:21:00 +00:00
showToast ( t ( 'cmd_steer_delivered' ) , 2500 ) ;
2026-06-22 06:22:51 +00:00
return true ;
2026-04-26 02:21:00 +00:00
}
2026-07-09 17:04:21 +00:00
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 ;
}
2026-06-22 06:22:51 +00:00
// 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.
2026-07-07 16:32:26 +00:00
const fallbackCode = result && result . fallback ;
const deadRunFallback = _steerFallbackIsDeadRun ( fallbackCode ) ;
const applyCurrentFailure = ! deadRunFallback || _steerOwnerStreamIsCurrent ( ownerSid , ownerStreamId ) ;
if ( _steerOwnerIsCurrent ( ownerSid ) && applyCurrentFailure ) {
2026-07-05 06:50:34 +00:00
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 ) ;
2026-06-22 06:22:51 +00:00
}
2026-07-07 16:32:26 +00:00
const clearedDeadRun = deadRunFallback && _steerClearCurrentOwnerDeadRun ( ownerSid , ownerStreamId ) ;
if ( ! deadRunFallback || applyCurrentFailure ) showToast ( t ( _steerFailureMessageKey ( fallbackCode ) ) , 3500 ) ;
if ( _steerOwnerIsCurrent ( ownerSid ) && ( ! deadRunFallback || clearedDeadRun ) ) _showSteerRecovery ( originalMsg , explicitSteer , fallbackCode ) ;
2026-06-22 06:22:51 +00:00
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
}
2026-04-19 05:37:44 +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 ( ) ;
2026-04-19 05:37:44 +00:00
} 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 ) ) ;
2026-04-27 23:27:03 +00:00
if ( data && data . session ) { S . messages = data . session . messages || [ ] ; S . toolCalls = [ ] ; if ( typeof clearLiveToolCards === 'function' ) clearLiveToolCards ( ) ; if ( typeof _messagesTruncated !== 'undefined' ) _messagesTruncated = false ; renderMessages ( ) ; }
2026-04-19 05:37:44 +00:00
$ ( '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 ) ) ;
2026-04-27 23:27:03 +00:00
if ( data && data . session ) { S . messages = data . session . messages || [ ] ; S . toolCalls = [ ] ; if ( typeof clearLiveToolCards === 'function' ) clearLiveToolCards ( ) ; if ( typeof _messagesTruncated !== 'undefined' ) _messagesTruncated = false ; renderMessages ( ) ; }
2026-04-19 05:37:44 +00:00
showToast ( ` ↩ ${ t ( 'undid_n_messages' ) } ${ r . removed _count } ${ t ( 'undid_messages_suffix' ) } ` ) ;
} catch ( e ) { showToast ( t ( 'undo_failed' ) + e . message ) ; }
}
2026-04-24 01:24:51 +00:00
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 ( ) {
2026-04-19 05:37:44 +00:00
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 ( ) ;
2026-04-19 05:37:44 +00:00
}
2026-04-21 22:26:52 +00:00
function cmdReasoning ( args ) {
const arg = ( args || '' ) . trim ( ) . toLowerCase ( ) ;
const BRAIN = '\uD83E\uDDE0' ;
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
2026-06-13 17:59:55 +00:00
// Keep this WebUI effort list in sync with hermes-agent#29248.
const EFFORTS = [ 'none' , 'minimal' , 'low' , 'medium' , 'high' , 'xhigh' ] ;
2026-04-21 22:26:52 +00:00
// 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
2026-06-13 17:59:55 +00:00
+ ' | /reasoning show|hide|none|minimal|low|medium|high|xhigh' ;
2026-04-21 22:26:52 +00:00
}
if ( ! arg ) {
// Status — read from the same config.yaml keys the CLI uses.
2026-05-23 09:59:45 +00:00
const q = ( typeof _reasoningEffortQuery === 'function' ) ? _reasoningEffortQuery ( ) : '' ;
api ( '/api/reasoning' + q ) . then ( function ( st ) { showToast ( _fmtStatus ( st ) ) ; } )
2026-04-21 22:26:52 +00:00
. 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 ;
2026-06-11 04:25:17 +00:00
if ( ! on && typeof removeThinking === 'function' ) removeThinking ( ) ;
2026-04-21 22:26:52 +00:00
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 ;
2026-04-24 06:25:45 +00:00
showToast ( BRAIN + ' Reasoning effort: ' + eff + ' (saved; applies to next turn)' ) ;
2026-05-23 09:59:45 +00:00
if ( typeof _applyReasoningChip === 'function' ) _applyReasoningChip ( eff , st || { } ) ;
2026-04-21 22:26:52 +00:00
} )
. 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 ;
}
2026-04-19 05:37:44 +00:00
function cmdVoice ( ) {
const mic = document . getElementById ( 'btnMic' ) ;
2026-07-01 23:24:38 +00:00
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 ( _ ) { } }
2026-04-19 05:37:44 +00:00
showToast ( t ( 'cmd_voice_use_mic' ) ) ;
}
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
// ── 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 ) ; }
}
2026-05-01 05:32:45 +00:00
// ── 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 ; }
2026-07-03 00:58:30 +00:00
const readOnlySession = typeof _isReadOnlySession === 'function'
? _isReadOnlySession ( S . session )
: ! ! ( S . session && ( S . session . read _only || S . session . is _read _only ) ) ;
2026-07-04 18:09:24 +00:00
const branchableReadOnlySession = typeof _isBranchableReadOnlySession === 'function'
? _isBranchableReadOnlySession ( S . session )
: false ;
if ( readOnlySession && ! branchableReadOnlySession ) { showToast ( 'Read-only sessions cannot be forked.' , 3000 ) ; return ; }
2026-05-01 05:32:45 +00:00
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.
2026-05-13 16:57:40 +00:00
// 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.
2026-05-01 05:32:45 +00:00
async function forkFromMessage ( msgIdx ) {
if ( ! S . session || S . busy ) return ;
2026-07-03 00:58:30 +00:00
const readOnlySession = typeof _isReadOnlySession === 'function'
? _isReadOnlySession ( S . session )
: ! ! ( S . session && ( S . session . read _only || S . session . is _read _only ) ) ;
2026-07-04 18:09:24 +00:00
const branchableReadOnlySession = typeof _isBranchableReadOnlySession === 'function'
? _isBranchableReadOnlySession ( S . session )
: false ;
if ( readOnlySession && ! branchableReadOnlySession ) { showToast ( 'Read-only sessions cannot be forked.' , 3000 ) ; return ; }
2026-05-17 22:01:12 +00:00
const initialSid = S . session . session _id ;
2026-05-13 16:57:40 +00:00
// 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 ( ) ;
}
2026-05-17 22:01:12 +00:00
if ( ! S . session || S . session . session _id !== initialSid ) return ;
2026-05-01 05:32:45 +00:00
try {
const data = await api ( '/api/session/branch' , {
method : 'POST' ,
body : JSON . stringify ( {
2026-05-17 22:01:12 +00:00
session _id : initialSid ,
2026-05-13 16:57:40 +00:00
keep _count : absoluteKeepCount ,
2026-05-01 05:32:45 +00:00
} ) ,
} ) ;
if ( data && data . session _id ) {
await loadSession ( data . session _id ) ;
2026-05-17 22:01:12 +00:00
if ( typeof _ensureAllMessagesLoaded === 'function' ) await _ensureAllMessagesLoaded ( ) ;
2026-05-01 05:32:45 +00:00
if ( typeof renderSessionList === 'function' ) await renderSessionList ( ) ;
showToast ( t ( 'branch_forked' ) ) ;
}
} catch ( e ) { showToast ( t ( 'branch_failed' ) + e . message ) ; }
}
2026-04-19 05:37:44 +00:00
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 , '' ) ;
}
2026-06-12 00:58:51 +00:00
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 ;
}
2026-04-19 05:37:44 +00:00
function _buildSkillCommandEntry ( skill ) {
const skillName = String ( skill && skill . name || '' ) . trim ( ) ;
const slug = _skillCommandSlug ( skillName ) ;
if ( ! slug ) return null ;
2026-06-12 00:58:51 +00:00
if ( _getReservedSlashCommandSlugs ( ) . has ( slug ) ) return null ;
2026-04-19 05:37:44 +00:00
return { name : slug , desc : String ( skill && skill . description || '' ) . trim ( ) || t ( 'slash_skill_desc' ) , source : 'skill' , skillName } ;
}
2026-06-13 11:46:21 +00:00
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 ,
} ;
}
2026-04-19 05:37:44 +00:00
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 ;
}
2026-06-13 11:46:21 +00:00
async function loadBundleCommands ( force = false ) {
if ( _bundleCommandCacheReady && ! force ) return _bundleCommandCache ;
if ( _bundleCommandLoadPromise && ! force ) return _bundleCommandLoadPromise ;
_bundleCommandLoadPromise = ( async ( ) => {
try {
2026-06-13 22:53:46 +00:00
await loadAgentCommandMetadata ( ) ;
2026-06-13 11:46:21 +00:00
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 ;
}
2026-04-19 05:37:44 +00:00
function refreshSlashCommandDropdown ( ) {
const ta = $ ( 'msg' ) ; if ( ! ta ) return ;
const text = ta . value || '' ;
2026-06-14 18:54:18 +00:00
if ( text . indexOf ( '\n' ) !== - 1 || _activeSlashCommandOffset ( text ) < 0 ) { hideCmdDropdown ( ) ; return ; }
2026-04-20 22:04:28 +00:00
getSlashAutocompleteMatches ( text ) . then ( matches => {
if ( ( $ ( 'msg' ) . value || '' ) !== text ) return ;
if ( matches . length ) showCmdDropdown ( matches ) ; else hideCmdDropdown ( ) ;
} ) ;
2026-04-19 05:37:44 +00:00
}
function ensureSkillCommandsLoadedForAutocomplete ( ) {
if ( _skillCommandCacheReady || _skillCommandLoadPromise ) return ;
loadSkillCommands ( ) . then ( ( ) => { refreshSlashCommandDropdown ( ) ; } ) ;
2026-06-13 11:46:21 +00:00
if ( ! _bundleCommandCacheReady && ! _bundleCommandLoadPromise ) {
loadBundleCommands ( ) . then ( ( ) => { refreshSlashCommandDropdown ( ) ; } ) ;
}
2026-05-09 21:14:21 +00:00
// Also preload agent/plugin command metadata for autocomplete
if ( ! _agentCommandCacheReady && ! _agentCommandCachePromise ) {
loadAgentCommandMetadata ( ) . then ( ( ) => { refreshSlashCommandDropdown ( ) ; } ) ;
}
2026-04-19 05:37:44 +00:00
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx = - 1 ;
function showCmdDropdown ( matches ) {
const dd = $ ( 'cmdDropdown' ) ;
if ( ! dd ) return ;
dd . innerHTML = '' ;
2026-04-20 22:04:28 +00:00
_cmdSelectedIdx = matches . length ? 0 : - 1 ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
for ( let i = 0 ; i < matches . length ; i ++ ) {
const c = matches [ i ] ;
const el = document . createElement ( 'div' ) ;
el . className = 'cmd-item' ;
2026-04-20 22:04:28 +00:00
if ( i === _cmdSelectedIdx ) el . classList . add ( 'selected' ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
el . dataset . idx = i ;
2026-04-20 22:04:28 +00:00
const isSubArg = c . source === 'subarg' ;
2026-06-04 04:12:23 +00:00
const isPath = c . source === 'path' ;
2026-04-20 22:04:28 +00:00
const usage = ( ! isSubArg && c . arg ) ? ` <span class="cmd-item-arg"> ${ esc ( c . arg ) } </span> ` : '' ;
2026-06-13 11:46:21 +00:00
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>'
: '' ;
2026-04-19 05:37:44 +00:00
if ( c . source === 'skill' ) el . classList . add ( 'cmd-item-skill' ) ;
2026-06-04 04:12:23 +00:00
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
2026-04-20 22:04:28 +00:00
? ` <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 } ` ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
el . onmousedown = ( e ) => {
e . preventDefault ( ) ;
2026-06-04 04:12:23 +00:00
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 ;
}
2026-06-14 18:54:18 +00:00
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 ( ) ;
2026-06-13 11:46:21 +00:00
if ( ! isSubArg && c . source !== 'skill' && c . source !== 'bundle' && nextValue . endsWith ( ' ' ) && typeof getSlashAutocompleteMatches === 'function' ) {
2026-04-20 22:04:28 +00:00
getSlashAutocompleteMatches ( nextValue ) . then ( matches => {
if ( ( $ ( 'msg' ) . value || '' ) !== nextValue ) return ;
if ( matches . length ) showCmdDropdown ( matches ) ;
else hideCmdDropdown ( ) ;
} ) ;
} else {
hideCmdDropdown ( ) ;
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
} ;
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' ) ;
2026-04-22 05:55:09 +00:00
// 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' } ) ;
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
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 ( ) ;
}
2026-04-19 05:37:44 +00:00
// ── 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 ;