diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 77cd7b1678..43f2b5a169 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -258,6 +258,72 @@ def test_slash_exec_rejects_skill_commands(server): assert "skill command" in resp["error"]["message"] +@pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) +def test_slash_exec_rejects_pending_input_commands(server, cmd): + """slash.exec must reject commands that use _pending_input in the CLI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r1", + "method": "slash.exec", + "params": {"command": cmd, "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4018 + assert "pending-input command" in resp["error"]["message"] + + +def test_command_dispatch_queue_sends_message(server): + """command.dispatch /queue returns {type: 'send', message: ...} for the TUI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r1", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "tell me about quantum computing" + + +def test_command_dispatch_queue_requires_arg(server): + """command.dispatch /queue without an argument returns an error.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r2", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "", "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4004 + + +def test_command_dispatch_steer_fallback_sends_message(server): + """command.dispatch /steer with no active agent falls back to send.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r3", + "method": "command.dispatch", + "params": {"name": "steer", "arg": "focus on testing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "focus on testing" + + def test_command_dispatch_returns_skill_payload(server): """command.dispatch returns structured skill payload for the TUI to send().""" sid = "test-session" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 45c95a6dab..bf8425a8d1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2117,6 +2117,56 @@ def _(rid, params: dict) -> dict: except Exception: pass + # ── Commands that queue messages onto _pending_input in the CLI ─── + # In the TUI the slash worker subprocess has no reader for that queue, + # so we handle them here and return a structured payload. + + if name in ("queue", "q"): + if not arg: + return _err(rid, 4004, "usage: /queue ") + return _ok(rid, {"type": "send", "message": arg}) + + if name == "retry": + agent = session.get("agent") if session else None + if agent and hasattr(agent, "conversation_history"): + hist = agent.conversation_history or [] + for m in reversed(hist): + if m.get("role") == "user": + content = m.get("content", "") + if isinstance(content, list): + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" + ) + if content: + return _ok(rid, {"type": "send", "message": content}) + return _err(rid, 4018, "no previous user message to retry") + return _err(rid, 4018, "no active session to retry") + + if name == "steer": + if not arg: + return _err(rid, 4004, "usage: /steer ") + agent = session.get("agent") if session else None + if agent and hasattr(agent, "steer"): + try: + accepted = agent.steer(arg) + if accepted: + return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}"}) + except Exception: + pass + # Fallback: no active run, treat as next-turn message + return _ok(rid, {"type": "send", "message": arg}) + + if name == "plan": + try: + from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path + plan_path = build_plan_path(session.get("session_key", "") if session else "") + msg = _bsim("/plan", f"{arg} {plan_path}".strip() if arg else plan_path, + task_id=session.get("session_key", "") if session else "") + if msg: + return _ok(rid, {"type": "send", "message": msg}) + except Exception as e: + return _err(rid, 5030, f"plan skill failed: {e}") + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") @@ -2338,9 +2388,23 @@ def _(rid, params: dict) -> dict: # _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). + # + # The same applies to /retry, /queue, /steer, and /plan — they all + # put messages on _pending_input that the slash worker never reads. + # (/browser connect/disconnect also uses _pending_input for context + # notes, but the actual browser operations need the slash worker's + # env-var side effects, so they stay in slash.exec — only the context + # note to the model is lost, which is low-severity.) + _PENDING_INPUT_COMMANDS = frozenset({"retry", "queue", "q", "steer", "plan"}) + _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() + _cmd_base = _cmd_parts[0] if _cmd_parts else "" + + if _cmd_base in _PENDING_INPUT_COMMANDS: + return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}") + 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]}" + _cmd_key = f"/{_cmd_base}" if _cmd_key in scan_skill_commands(): return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") except Exception: diff --git a/ui-tui/src/__tests__/asCommandDispatch.test.ts b/ui-tui/src/__tests__/asCommandDispatch.test.ts index 49ea56936c..dfa7595174 100644 --- a/ui-tui/src/__tests__/asCommandDispatch.test.ts +++ b/ui-tui/src/__tests__/asCommandDispatch.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { asCommandDispatch } from '../lib/rpc.js' describe('asCommandDispatch', () => { - it('parses exec, alias, and skill', () => { + it('parses exec, alias, skill, and send', () => { expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' }) expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' }) expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({ @@ -11,11 +11,17 @@ describe('asCommandDispatch', () => { name: 'x', message: 'do' }) + expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({ + type: 'send', + message: 'hello world' + }) }) it('rejects malformed payloads', () => { expect(asCommandDispatch(null)).toBeNull() expect(asCommandDispatch({ type: 'alias' })).toBeNull() expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull() + expect(asCommandDispatch({ type: 'send' })).toBeNull() + expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull() }) }) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index a8f050a27d..53a10fd8e0 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -152,6 +152,36 @@ describe('createSlashHandler', () => { }) expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) }) + + it('handles send-type dispatch for /plan command', async () => { + const planMessage = 'Plan skill content loaded' + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('pending-input command')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'send', message: planMessage }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/plan create a REST API')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage) + }) + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 87475341ae..425e778ef3 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) } + + if (d.type === 'send') { + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + } }) .catch(guardedErr) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 73ea9febda..46ab21c725 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + // ── Terminal tab title ───────────────────────────────────────────── + // Show model name + status so users can identify the Hermes tab. + const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' + const titleStatus = ui.busy ? '⏳' : '✓' + const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes' + useTerminalTitle(terminalTitle) + useEffect(() => { if (!ui.sid || !stdout) { return diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 59db604e4b..9cf78c1590 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -28,12 +28,18 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role === 'tool') { + const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || + '(empty tool result)' + return ( - - {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || - '(empty tool result)'} - + {hasAnsi(msg.text) ? ( + {compactPreview(msg.text, Math.max(24, cols - 14)) || '(empty tool result)'} + ) : ( + + {preview} + + )} ) } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c8d1c68552..e17e0e7c71 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -47,6 +47,7 @@ export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } | { message?: string; name: string; type: 'skill' } + | { message: string; type: 'send' } // ── Config ─────────────────────────────────────────────────────────── diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 1697d142bb..70faa4bbbe 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } } + if (t === 'send' && typeof o.message === 'string') { + return { type: 'send', message: o.message } + } + return null } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index faab71ae93..6815e4211b 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -93,6 +93,7 @@ declare module '@hermes/ink' { export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean + export function useTerminalTitle(title: string | null): void export function useDeclaredCursor(args: { readonly line: number readonly column: number