From 2da558ec36ea7c8743f0e686488af57da8be1634 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:36:06 +0530 Subject: [PATCH] fix(tui): clickable hyperlinks and skill slash command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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(). --- tests/tui_gateway/test_protocol.py | 48 +++++++++++++++++++ tui_gateway/server.py | 13 +++++ .../src/__tests__/createSlashHandler.test.ts | 31 ++++++++++++ ui-tui/src/components/markdown.tsx | 22 +++++---- ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 106 insertions(+), 9 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 6ee5fe65b6..77cd7b1678 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -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" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7dae9e5c6..45c95a6dab 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9e1db99463..a8f050a27d 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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 => ({ diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 865ab85796..4555c8505f 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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) => ( - - {label} - +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + + + {label} + + ) const trimBareUrl = (value: string) => { @@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => { } const renderAutolink = (key: number, t: Theme, raw: string) => ( - - {raw.replace(/^mailto:/, '')} - + + + {raw.replace(/^mailto:/, '')} + + ) const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } 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]) { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9b2deec35f..faab71ae93 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,6 +63,7 @@ declare module '@hermes/ink' { export const Box: React.ComponentType export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }> export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType