{"repo":"NousResearch/hermes-agent","public_id":"12143","display_label":"TUI slash command dispatch bugs and fixes","summary":"These members concern slash commands (/steer, /queue, and custom skill-bundle commands) silently failing or misbehaving in the TUI and CLI interfaces. The group includes bug reports and corresponding fix attempts addressing command dispatch, idle-state handling, and related TUI behavior.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":3,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12141},"resolution":{"state":"resolved","by":"pr-12141,pr-62859,pr-121077"},"freshness":{"state":"verified","last_verified_at":"2026-09-24T07:40:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.24799999999999997},"fix_edges":[{"issue_number":12142,"pr_number":12141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12143,"pr_number":12141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37254,"pr_number":62859,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":62863,"pr_number":62859,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":64578,"pr_number":121077,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":103287,"pr_number":121077,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":12143,"title":"TUI: Skill slash commands silently do nothing","url":null,"state":"closed"},{"kind":"issue","number":62863,"title":"[Bug]: custom skill-bundle slash commands hang under --tui","url":null,"state":"closed"},{"kind":"issue","number":64578,"title":"[Bug]: /steer and /queue submitted after response completes are silently demoted or stranded (CLI + Desktop)","url":null,"state":"closed"},{"kind":"pr","number":12141,"title":"fix(tui): clickable hyperlinks and skill slash command dispatch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py","ui-tui/src/__tests__/asCommandDispatch.test.ts","ui-tui/src/__tests__/createSlashHandler.test.ts","ui-tui/src/app/createSlashHandler.ts","ui-tui/src/app/useMainApp.ts","ui-tui/src/components/markdown.tsx","ui-tui/src/components/messageLine.tsx","ui-tui/src/gatewayTypes.ts","ui-tui/src/lib/rpc.ts","ui-tui/src/types/hermes-ink.d.ts"],"fix_hunks":[{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -231,3 +231,201 @@ def test_cli_exec_blocked(server, argv):\n ])\n def test_cli_exec_allowed(server, argv):\n     assert server._cli_exec_blocked(argv) is None\n+\n+\n+# ── slash.exec skill command interception ────────────────────────────\n+\n+\n+def test_slash_exec_rejects_skill_commands(server):\n+    \"\"\"slash.exec must reject skill commands so the TUI falls through to command.dispatch.\"\"\"\n+    # Register a mock session\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None}\n+\n+    # Mock scan_skill_commands to return a known skill\n+    fake_skills = {\"/hermes-agent-dev\": {\"name\": \"hermes-agent-dev\", \"description\": \"Dev workflow\"}}\n+\n+    with patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_skills):\n+        resp = server.handle_request({\n+            \"id\": \"r1\",\n+            \"method\": \"slash.exec\",\n+            \"params\": {\"command\": \"hermes-agent-dev\", \"session_id\": sid},\n+        })\n+\n+    # Should return an error so the TUI's .catch() fires command.dispatch\n+    assert \"error\" in resp\n+    assert resp[\"error\"][\"code\"] == 4018\n+    assert \"skill command\" in resp[\"error\"][\"message\"]\n+\n+\n[redacted:email](\"cmd\", [\"retry\", \"queue hello\", \"q hello\", \"steer fix the test\", \"plan\"])\n+def test_slash_exec_rejects_pending_input_commands(server, cmd):\n+    \"\"\"slash.exec must reject commands that use _pending_input in the CLI.\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None}\n+\n+    resp = server.handle_request({\n+        \"id\": \"r1\",\n+        \"method\": \"slash.exec\",\n+        \"params\": {\"command\": cmd, \"session_id\": sid},\n+    })\n+\n+    assert \"error\" in resp\n+    assert resp[\"error\"][\"code\"] == 4018\n+    assert \"pending-input command\" in resp[\"error\"][\"message\"]\n+\n+\n+def test_command_dispatch_queue_sends_message(server):\n+    \"\"\"command.dispatch /queue returns {type: 'send', message: ...} for the TUI.\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid}\n+\n+    resp = server.handle_request({\n+        \"id\": \"r1\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"queue\", \"arg\": \"tell me about quantum computing\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" not in resp\n+    result = resp[\"result\"]\n+    assert result[\"type\"] == \"send\"\n+    assert result[\"message\"] == \"tell me about quantum computing\"\n+\n+\n+def test_command_dispatch_queue_requires_arg(server):\n+    \"\"\"command.dispatch /queue without an argument returns an error.\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid}\n+\n+    resp = server.handle_request({\n+        \"id\": \"r2\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"queue\", \"arg\": \"\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" in resp\n+    assert resp[\"error\"][\"code\"] == 4004\n+\n+\n+def test_command_dispatch_steer_fallback_sends_message(server):\n+    \"\"\"command.dispatch /steer with no active agent falls back to send.\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None}\n+\n+    resp = server.handle_request({\n+        \"id\": \"r3\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"steer\", \"arg\": \"focus on testing\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" not in resp\n+    result = resp[\"result\"]\n+    assert result[\"type\"] == \"send\"\n+    assert result[\"message\"] == \"focus on testing\"\n+\n+\n+def test_command_dispatch_retry_finds_last_user_message(server):\n+    \"\"\"command.dispatch /retry walks session['history'] to find the last user message.\"\"\"\n+    sid = \"test-session\"\n+    history = [\n+        {\"role\": \"user\", \"content\": \"first question\"},\n+        {\"role\": \"assistant\", \"content\": \"first answer\"},\n+        {\"role\": \"user\", \"content\": \"second question\"},\n+        {\"role\": \"assistant\", \"content\": \"second answer\"},\n+    ]\n+    server._sessions[sid] = {\n+        \"session_key\": sid,\n+        \"agent\": None,\n+        \"history\": history,\n+        \"history_lock\": threading.Lock(),\n+        \"history_version\": 0,\n+    }\n+\n+    resp = server.handle_request({\n+        \"id\": \"r4\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"retry\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" not in resp\n+    result = resp[\"result\"]\n+    assert result[\"type\"] == \"send\"\n+    assert result[\"message\"] == \"second question\"\n+    # Verify history was truncated: everything from last user message onward removed\n+    assert len(server._sessions[sid][\"history\"]) == 2\n+    assert server._sessions[sid][\"history\"][-1][\"role\"] == \"assistant\"\n+    assert server._sessions[sid][\"history_version\"] == 1\n+\n+\n+def test_command_dispatch_retry_empty_history(server):\n+    \"\"\"command.dispatch /retry with empty history returns error.\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\n+        \"session_key\": sid,\n+        \"agent\": None,\n+        \"history\": [],\n+        \"history_lock\": threading.Lock(),\n+        \"history_version\": 0,\n+    }\n+\n+    resp = server.handle_request({\n+        \"id\": \"r5\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"retry\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" in resp\n+    assert resp[\"error\"][\"code\"] == 4018\n+\n+\n+def test_command_dispatch_retry_handles_multipart_content(server):\n+    \"\"\"command.dispatch /retry extracts text from multipart content lists.\"\"\"\n+    sid = \"test-session\"\n+    history = [\n+        {\"role\": \"user\", \"content\": [\n+            {\"type\": \"text\", \"text\": \"analyze this\"},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,...\"}}\n+        ]},\n+        {\"role\": \"assistant\", \"content\": \"I see the image.\"},\n+    ]\n+    server._sessions[sid] = {\n+        \"session_key\": sid,\n+        \"agent\": None,\n+        \"history\": history,\n+        \"history_lock\": threading.Lock(),\n+        \"history_version\": 0,\n+    }\n+\n+    resp = server.handle_request({\n+        \"id\": \"r6\",\n+        \"method\": \"command.dispatch\",\n+        \"params\": {\"name\": \"retry\", \"session_id\": sid},\n+    })\n+\n+    assert \"error\" not in resp\n+    result = resp[\"result\"]\n+    assert result[\"type\"] == \"send\"\n+    assert result[\"message\"] == \"analyze this\"\n+\n+\n+def test_command_dispatch_returns_skill_payload(server):\n+    \"\"\"command.dispatch returns structured skill payload for the TUI to send().\"\"\"\n+    sid = \"test-session\"\n+    server._sessions[sid] = {\"session_key\": sid}\n+\n+    fake_skills = {\"/hermes-agent-dev\": {\"name\": \"hermes-agent-dev\", \"description\": \"Dev workflow\"}}\n+    fake_msg = \"Loaded skill content here\"\n+\n+    with patch(\"agent.skill_commands.scan_skill_commands\", return_value=fake_skills), \\\n+         patch(\"agent.skill_commands.build_skill_invocation_message\", return_value=fake_msg):\n+        resp = server.handle_request({\n+            \"id\": \"r2\",\n+            \"method\": \"command.dispatch\",\n+            \"params\": {\"name\": \"hermes-agent-dev\", \"session_id\": sid},\n+        })\n+\n+    assert \"error\" not in resp\n+    result = resp[\"result\"]\n+    assert result[\"type\"] == \"skill\"\n+    assert result[\"message\"] == fake_msg\n+    assert result[\"name\"] == \"hermes-agent-dev\""]},{"file":"tui_gateway/server.py","hunks":["@@ -1949,6 +1949,13 @@ def _(rid, params: dict) -> dict:\n     (\"/logs\", \"Show recent gateway log lines\", \"TUI\"),\n ]\n \n+# Commands that queue messages onto _pending_input in the CLI.\n+# In the TUI the slash worker subprocess has no reader for that queue,\n+# so slash.exec rejects them → TUI falls through to command.dispatch.\n+_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({\n+    \"retry\", \"queue\", \"q\", \"steer\", \"plan\",\n+})\n+\n \n @method(\"commands.catalog\")\n def _(rid, params: dict) -> dict:","@@ -2117,6 +2124,75 @@ def _(rid, params: dict) -> dict:\n     except Exception:\n         pass\n \n+    # ── Commands that queue messages onto _pending_input in the CLI ───\n+    # In the TUI the slash worker subprocess has no reader for that queue,\n+    # so we handle them here and return a structured payload.\n+\n+    if name in (\"queue\", \"q\"):\n+        if not arg:\n+            return _err(rid, 4004, \"usage: /queue <prompt>\")\n+        return _ok(rid, {\"type\": \"send\", \"message\": arg})\n+\n+    if name == \"retry\":\n+        if not session:\n+            return _err(rid, 4001, \"no active session to retry\")\n+        history = session.get(\"history\", [])\n+        if not history:\n+            return _err(rid, 4018, \"no previous user message to retry\")\n+        # Walk backwards to find the last user message\n+        last_user_idx = None\n+        for i in range(len(history) - 1, -1, -1):\n+            if history[i].get(\"role\") == \"user\":\n+                last_user_idx = i\n+                break\n+        if last_user_idx is None:\n+            return _err(rid, 4018, \"no previous user message to retry\")\n+        content = history[last_user_idx].get(\"content\", \"\")\n+        if isinstance(content, list):\n+            content = \" \".join(\n+                p.get(\"text\", \"\") for p in content if isinstance(p, dict) and p.get(\"type\") == \"text\"\n+            )\n+        if not content:\n+            return _err(rid, 4018, \"last user message is empty\")\n+        # Truncate history: remove everything from the last user message onward\n+        # (mirrors CLI retry_last() which strips the failed exchange)\n+        with session[\"history_lock\"]:\n+            session[\"history\"] = history[:last_user_idx]\n+            session[\"history_version\"] = int(session.get(\"history_version\", 0)) + 1\n+        return _ok(rid, {\"type\": \"send\", \"message\": content})\n+\n+    if name == \"steer\":\n+        if not arg:\n+            return _err(rid, 4004, \"usage: /steer <prompt>\")\n+        agent = session.get(\"agent\") if session else None\n+        if agent and hasattr(agent, \"steer\"):\n+            try:\n+                accepted = agent.steer(arg)\n+                if accepted:\n+                    return _ok(rid, {\"type\": \"exec\", \"output\": f\"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}\"})\n+            except Exception:\n+                pass\n+        # Fallback: no active run, treat as next-turn message\n+        return _ok(rid, {\"type\": \"send\", \"message\": arg})\n+\n+    if name == \"plan\":\n+        try:\n+            from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path\n+            user_instruction = arg or \"\"\n+            plan_path = build_plan_path(user_instruction)\n+            msg = _bsim(\n+                \"/plan\", user_instruction,\n+                task_id=session.get(\"session_key\", \"\") if session else \"\",\n+                runtime_note=(\n+                    \"Save the markdown plan with write_file to this exact relative path \"\n+                    f\"inside the active workspace/backend cwd: {plan_path}\"\n+                ),\n+            )\n+            if msg:\n+                return _ok(rid, {\"type\": \"send\", \"message\": msg})\n+        except Exception as e:\n+            return _err(rid, 5030, f\"plan skill failed: {e}\")\n+\n     return _err(rid, 4018, f\"not a quick/plugin/skill command: {name}\")\n \n ","@@ -2333,6 +2409,26 @@ def _(rid, params: dict) -> dict:\n     if not cmd:\n         return _err(rid, 4004, \"empty command\")\n \n+    # Skill slash commands and _pending_input commands must NOT go through the\n+    # slash worker — see _PENDING_INPUT_COMMANDS definition above.\n+    # (/browser connect/disconnect also uses _pending_input for context\n+    # notes, but the actual browser operations need the slash worker's\n+    # env-var side effects, so they stay in slash.exec — only the context\n+    # note to the model is lost, which is low-severity.)\n+    _cmd_parts = cmd.split() if not cmd.startswith(\"/\") else cmd.lstrip(\"/\").split()\n+    _cmd_base = _cmd_parts[0] if _cmd_parts else \"\"\n+\n+    if _cmd_base in _PENDING_INPUT_COMMANDS:\n+        return _err(rid, 4018, f\"pending-input command: use command.dispatch for /{_cmd_base}\")\n+\n+    try:\n+        from agent.skill_commands import get_skill_commands\n+        _cmd_key = f\"/{_cmd_base}\"\n+        if _cmd_key in get_skill_commands():\n+            return _err(rid, 4018, f\"skill command: use command.dispatch for {_cmd_key}\")\n+    except Exception:\n+        pass\n+\n     worker = session.get(\"slash_worker\")\n     if not worker:\n         try:"]},{"file":"ui-tui/src/__tests__/asCommandDispatch.test.ts","hunks":["@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'\n import { asCommandDispatch } from '../lib/rpc.js'\n \n describe('asCommandDispatch', () => {\n-  it('parses exec, alias, and skill', () => {\n+  it('parses exec, alias, skill, and send', () => {\n     expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })\n     expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })\n     expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({","@@ -11,11 +11,17 @@ describe('asCommandDispatch', () => {\n       name: 'x',\n       message: 'do'\n     })\n+    expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({\n+      type: 'send',\n+      message: 'hello world'\n+    })\n   })\n \n   it('rejects malformed payloads', () => {\n     expect(asCommandDispatch(null)).toBeNull()\n     expect(asCommandDispatch({ type: 'alias' })).toBeNull()\n     expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()\n+    expect(asCommandDispatch({ type: 'send' })).toBeNull()\n+    expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull()\n   })\n })"]},{"file":"ui-tui/src/__tests__/createSlashHandler.test.ts","hunks":["@@ -121,6 +121,67 @@ describe('createSlashHandler', () => {\n     expect(createSlashHandler(ctx)('/h')).toBe(true)\n     expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))\n   })\n+\n+  it('falls through to command.dispatch for skill commands and sends the message', async () => {\n+    const skillMessage = 'Use this skill to do X.\\n\\n## Steps\\n1. First step'\n+\n+    const ctx = buildCtx({\n+      gateway: {\n+        gw: {\n+          getLogTail: vi.fn(() => ''),\n+          request: vi.fn((method: string) => {\n+            if (method === 'slash.exec') {\n+              return Promise.reject(new Error('skill command: use command.dispatch'))\n+            }\n+\n+            if (method === 'command.dispatch') {\n+              return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' })\n+            }\n+\n+            return Promise.resolve({})\n+          })\n+        },\n+        rpc: vi.fn(() => Promise.resolve({}))\n+      }\n+    })\n+\n+    const h = createSlashHandler(ctx)\n+    expect(h('/hermes-agent-dev')).toBe(true)\n+    await vi.waitFor(() => {\n+      expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev')\n+    })\n+    expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)\n+  })\n+\n+  it('handles send-type dispatch for /plan command', async () => {\n+    const planMessage = 'Plan skill content loaded'\n+\n+    const ctx = buildCtx({\n+      gateway: {\n+        gw: {\n+          getLogTail: vi.fn(() => ''),\n+          request: vi.fn((method: string) => {\n+            if (method === 'slash.exec') {\n+              return Promise.reject(new Error('pending-input command'))\n+            }\n+\n+            if (method === 'command.dispatch') {\n+              return Promise.resolve({ type: 'send', message: planMessage })\n+            }\n+\n+            return Promise.resolve({})\n+          })\n+        },\n+        rpc: vi.fn(() => Promise.resolve({}))\n+      }\n+    })\n+\n+    const h = createSlashHandler(ctx)\n+    expect(h('/plan create a REST API')).toBe(true)\n+    await vi.waitFor(() => {\n+      expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage)\n+    })\n+  })\n })\n \n const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({"]},{"file":"ui-tui/src/app/createSlashHandler.ts","hunks":["@@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b\n \n               return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`)\n             }\n+\n+            if (d.type === 'send') {\n+              return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)\n+            }\n           })\n           .catch(guardedErr)\n       })"]},{"file":"ui-tui/src/app/useMainApp.ts","hunks":["@@ -1,4 +1,4 @@\n-import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink'\n+import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'\n import { useStore } from '@nanostores/react'\n import { useCallback, useEffect, useMemo, useRef, useState } from 'react'\n ","@@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) {\n \n   useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })\n \n+  // ── Terminal tab title ─────────────────────────────────────────────\n+  // Show model name + status so users can identify the Hermes tab.\n+  const shortModel = ui.info?.model?.replace(/^.*\\//, '') ?? ''\n+  const titleStatus = ui.busy ? '⏳' : '✓'\n+  const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'\n+  useTerminalTitle(terminalTitle)\n+\n   useEffect(() => {\n     if (!ui.sid || !stdout) {\n       return"]},{"file":"ui-tui/src/components/markdown.tsx","hunks":["@@ -1,4 +1,4 @@\n-import { Box, Text } from '@hermes/ink'\n+import { Box, Link, Text } from '@hermes/ink'\n import { memo, type ReactNode, useMemo } from 'react'\n \n import type { Theme } from '../theme.js'","@@ -22,10 +22,12 @@ type Fence = {\n   len: number\n }\n \n-const renderLink = (key: number, t: Theme, label: string) => (\n-  <Text color={t.color.amber} key={key} underline>\n-    {label}\n-  </Text>\n+const renderLink = (key: number, t: Theme, label: string, url: string) => (\n+  <Link key={key} url={url}>\n+    <Text color={t.color.amber} underline>\n+      {label}\n+    </Text>\n+  </Link>\n )\n \n const trimBareUrl = (value: string) => {","@@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => {\n }\n \n const renderAutolink = (key: number, t: Theme, raw: string) => (\n-  <Text color={t.color.amber} key={key} underline>\n-    {raw.replace(/^mailto:/, '')}\n-  </Text>\n+  <Link key={key} url={raw}>\n+    <Text color={t.color.amber} underline>\n+      {raw.replace(/^mailto:/, '')}\n+    </Text>\n+  </Link>\n )\n \n const indentDepth = (indent: string) => Math.floor(indent.replace(/\\t/g, '  ').length / 2)","@@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {\n         </Text>\n       )\n     } else if (m[4] && m[5]) {\n-      parts.push(renderLink(parts.length, t, m[4]))\n+      parts.push(renderLink(parts.length, t, m[4], m[5]))\n     } else if (m[6]) {\n       parts.push(renderAutolink(parts.length, t, m[6]))\n     } else if (m[7]) {"]},{"file":"ui-tui/src/components/messageLine.tsx","hunks":["@@ -28,12 +28,19 @@ export const MessageLine = memo(function MessageLine({\n   }\n \n   if (msg.role === 'tool') {\n+    const maxChars = Math.max(24, cols - 14)\n+    const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text\n+    const preview = compactPreview(stripped, maxChars) || '(empty tool result)'\n+\n     return (\n       <Box alignSelf=\"flex-start\" borderColor={t.color.dim} borderStyle=\"round\" marginLeft={3} paddingX={1}>\n-        <Text color={t.color.dim} wrap=\"truncate-end\">\n-          {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||\n-            '(empty tool result)'}\n-        </Text>\n+        {hasAnsi(msg.text) ? (\n+          <Text wrap=\"truncate-end\"><Ansi>{msg.text}</Ansi></Text>\n+        ) : (\n+          <Text color={t.color.dim} wrap=\"truncate-end\">\n+            {preview}\n+          </Text>\n+        )}\n       </Box>\n     )\n   }"]},{"file":"ui-tui/src/gatewayTypes.ts","hunks":["@@ -47,6 +47,7 @@ export type CommandDispatchResponse =\n   | { output?: string; type: 'exec' | 'plugin' }\n   | { target: string; type: 'alias' }\n   | { message?: string; name: string; type: 'skill' }\n+  | { message: string; type: 'send' }\n \n // ── Config ───────────────────────────────────────────────────────────\n "]},{"file":"ui-tui/src/lib/rpc.ts","hunks":["@@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul\n     return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }\n   }\n \n+  if (t === 'send' && typeof o.message === 'string') {\n+    return { type: 'send', message: o.message }\n+  }\n+\n   return null\n }\n "]},{"file":"ui-tui/src/types/hermes-ink.d.ts","hunks":["@@ -63,6 +63,7 @@ declare module '@hermes/ink' {\n   export const Box: React.ComponentType<any>\n   export const AlternateScreen: React.ComponentType<any>\n   export const Ansi: React.ComponentType<any>\n+  export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }>\n   export const NoSelect: React.ComponentType<any>\n   export const ScrollBox: React.ComponentType<any>\n   export const Text: React.ComponentType<any>","@@ -92,6 +93,7 @@ declare module '@hermes/ink' {\n   export function useHasSelection(): boolean\n   export function useStdout(): { readonly stdout?: NodeJS.WriteStream }\n   export function useTerminalFocus(): boolean\n+  export function useTerminalTitle(title: string | null): void\n   export function useDeclaredCursor(args: {\n     readonly line: number\n     readonly column: number\n"]}]},{"kind":"pr","number":62859,"title":"fix(tui): dispatch custom skill bundles as agent turns","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py"]},{"kind":"pr","number":121077,"title":"fix(tui-gateway): never park an idle /steer on the agent","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_tui_gateway_server.py","tui_gateway/methods_session.py","tui_gateway/methods_tools.py","ui-tui/src/__tests__/steerCommand.test.ts","ui-tui/src/app/slash/commands/core.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}