fix(tui): clickable hyperlinks and skill slash command dispatch

Two TUI fixes:

1. Hyperlinks are now clickable (Cmd+Click / Ctrl+Click) in terminals
   that support OSC 8.  The markdown renderer was rendering links as
   plain colored text — now wraps them in the existing <Link> component
   from @hermes/ink which emits OSC 8 escape sequences.

2. Skill slash commands (e.g. /hermes-agent-dev) now work in the TUI.
   The slash.exec handler was delegating to the _SlashWorker subprocess
   which calls cli.process_command().  For skills, process_command()
   queues the invocation message onto _pending_input — a Queue that
   nobody reads in the worker subprocess.  The skill message was lost.
   Now slash.exec detects skill commands early and rejects them so
   the TUI falls through to command.dispatch, which correctly builds
   and returns the skill payload for the client to send().
This commit is contained in:
kshitijk4poor
2026-04-18 17:36:06 +05:30
committed by kshitij
parent b0efdf37d7
commit 2da558ec36
5 changed files with 106 additions and 9 deletions

View File

@@ -231,3 +231,51 @@ def test_cli_exec_blocked(server, argv):
])
def test_cli_exec_allowed(server, argv):
assert server._cli_exec_blocked(argv) is None
# ── slash.exec skill command interception ────────────────────────────
def test_slash_exec_rejects_skill_commands(server):
"""slash.exec must reject skill commands so the TUI falls through to command.dispatch."""
# Register a mock session
sid = "test-session"
server._sessions[sid] = {"session_key": sid, "agent": None}
# Mock scan_skill_commands to return a known skill
fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}}
with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills):
resp = server.handle_request({
"id": "r1",
"method": "slash.exec",
"params": {"command": "hermes-agent-dev", "session_id": sid},
})
# Should return an error so the TUI's .catch() fires command.dispatch
assert "error" in resp
assert resp["error"]["code"] == 4018
assert "skill command" in resp["error"]["message"]
def test_command_dispatch_returns_skill_payload(server):
"""command.dispatch returns structured skill payload for the TUI to send()."""
sid = "test-session"
server._sessions[sid] = {"session_key": sid}
fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}}
fake_msg = "Loaded skill content here"
with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills), \
patch("agent.skill_commands.build_skill_invocation_message", return_value=fake_msg):
resp = server.handle_request({
"id": "r2",
"method": "command.dispatch",
"params": {"name": "hermes-agent-dev", "session_id": sid},
})
assert "error" not in resp
result = resp["result"]
assert result["type"] == "skill"
assert result["message"] == fake_msg
assert result["name"] == "hermes-agent-dev"

View File

@@ -2333,6 +2333,19 @@ def _(rid, params: dict) -> dict:
if not cmd:
return _err(rid, 4004, "empty command")
# Skill slash commands (e.g. /hermes-agent-dev) must NOT go through the
# slash worker — process_command() queues the skill payload onto
# _pending_input which nobody reads in the worker subprocess. Reject
# here so the TUI falls through to command.dispatch which handles skills
# correctly (builds the invocation message and returns it to the client).
try:
from agent.skill_commands import scan_skill_commands
_cmd_key = f"/{cmd.split()[0]}" if not cmd.startswith("/") else f"/{cmd.lstrip('/').split()[0]}"
if _cmd_key in scan_skill_commands():
return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}")
except Exception:
pass
worker = session.get("slash_worker")
if not worker:
try:

View File

@@ -121,6 +121,37 @@ describe('createSlashHandler', () => {
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
it('falls through to command.dispatch for skill commands and sends the message', async () => {
const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('skill command: use command.dispatch'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/hermes-agent-dev')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev')
})
expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)
})
})
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({

View File

@@ -1,4 +1,4 @@
import { Box, Text } from '@hermes/ink'
import { Box, Link, Text } from '@hermes/ink'
import { memo, type ReactNode, useMemo } from 'react'
import type { Theme } from '../theme.js'
@@ -22,10 +22,12 @@ type Fence = {
len: number
}
const renderLink = (key: number, t: Theme, label: string) => (
<Text color={t.color.amber} key={key} underline>
{label}
</Text>
const renderLink = (key: number, t: Theme, label: string, url: string) => (
<Link key={key} url={url}>
<Text color={t.color.amber} underline>
{label}
</Text>
</Link>
)
const trimBareUrl = (value: string) => {
@@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => {
}
const renderAutolink = (key: number, t: Theme, raw: string) => (
<Text color={t.color.amber} key={key} underline>
{raw.replace(/^mailto:/, '')}
</Text>
<Link key={key} url={raw}>
<Text color={t.color.amber} underline>
{raw.replace(/^mailto:/, '')}
</Text>
</Link>
)
const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
@@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
</Text>
)
} else if (m[4] && m[5]) {
parts.push(renderLink(parts.length, t, m[4]))
parts.push(renderLink(parts.length, t, m[4], m[5]))
} else if (m[6]) {
parts.push(renderAutolink(parts.length, t, m[6]))
} else if (m[7]) {

View File

@@ -63,6 +63,7 @@ declare module '@hermes/ink' {
export const Box: React.ComponentType<any>
export const AlternateScreen: React.ComponentType<any>
export const Ansi: React.ComponentType<any>
export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }>
export const NoSelect: React.ComponentType<any>
export const ScrollBox: React.ComponentType<any>
export const Text: React.ComponentType<any>