From 7f0bdcbbc15ad6045500362c5b944421f6745373 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 22 Aug 2026 20:26:38 +0200 Subject: [PATCH] Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji) tool.start gains an optional cosmetic 'emoji' field, resolved server-side through hermes' own display layer (active-skin overrides, then the tool registry's per-tool emoji) so icons track the user's hermes theme and new/plugin tools get their glyph for free. Omitted for unknown tools so the app falls back to its default wrench. - protocol.py: tool_start(emoji=...) kwarg, payload field when set - adapter.py: _tool_emoji() helper (lazy import, None on unknown/failure) - frames.schema.json + docs/04: field documented - app: ToolStartPayload.emoji -> ToolItem.emoji -> ToolCard header - tests: frame shape, resolution/fallback, end-to-end tool.start emoji --- .../commonMain/kotlin/iris/data/ChatStore.kt | 4 ++ .../kotlin/iris/protocol/Protocol.kt | 3 + .../kotlin/iris/ui/screens/ChatScreen.kt | 2 +- docs/04-wire-protocol.md | 7 ++- docs/protocol/frames.schema.json | 2 +- gateway-plugin/adapter.py | 19 ++++++ gateway-plugin/protocol.py | 3 + gateway-plugin/tests/test_android.py | 61 +++++++++++++++++++ 8 files changed, 98 insertions(+), 3 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt index d6eb39d..6fa0c88 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt @@ -103,6 +103,9 @@ data class ToolItem( val name: String, val preview: String? = null, val args: JsonElement? = null, + /** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji); + * null for unknown tools / older frames β€” the UI falls back to πŸ”§. */ + val emoji: String? = null, val note: String? = null, val done: Boolean = false, val ok: Boolean = true, @@ -450,6 +453,7 @@ class ChatStore { name = p.name, preview = p.preview, args = p.args, + emoji = p.emoji, anchorId = anchorId, ) } diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index b373c3b..fc8d45b 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -271,6 +271,9 @@ data class ToolStartPayload( val name: String, val preview: String? = null, val args: JsonElement? = null, + /** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji); + * absent for unknown tools β€” the UI falls back to its default. */ + val emoji: String? = null, ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 0ef8012..63d5d5f 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -2767,7 +2767,7 @@ private fun ToolCard( Spacer(modifier = Modifier.width(6.dp)) } Text( - "πŸ”§ ${tool.name}", + "${tool.emoji ?: "πŸ”§"} ${tool.name}", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = IrisColors.textBright, diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index b3b4a3e..0dcc71a 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -126,7 +126,7 @@ truncated / nothing). ```json {"type":"tool.start","chat_id":"…","payload":{ - "index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}} + "index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"},"emoji":"πŸ’»"}} {"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}} {"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4, "output_preview":"12 passed"}} @@ -135,6 +135,11 @@ truncated / nothing). `args` may be large; the app truncates per its setting. `output_preview` is a short tail (full output is not streamed β€” it lives in agent history). +`emoji` is a cosmetic per-tool glyph resolved server-side via hermes' +`get_tool_emoji` (active-skin overrides, then the tool registry's per-tool +`emoji` β€” e.g. `read_file` πŸ“–, `write_file` ✍️, `terminal` πŸ’»). Omitted when +the tool is unknown, so the app falls back to its own default glyph. + ### `typing` / `typing.stop` ```json diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 3f6d349..0462d0c 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -47,7 +47,7 @@ "message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } }, "message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } }, "commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } }, - "tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } }, + "tool.start": { "description": "Cosmetic per-tool emoji (resolved server-side via hermes' get_tool_emoji: active-skin overrides, then the tool registry's per-tool emoji); omitted when the tool is unknown so the app falls back to its own default glyph.", "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" }, "emoji": { "type": "string" } } }, "tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } }, "tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } }, "typing": { "payload": { "on": { "type": "boolean" } } }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 80b2e64..ac97590 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -321,6 +321,24 @@ def _tool_end_fields(tool_name: str) -> dict[str, Any]: return fields +def _tool_emoji(tool_name: str) -> str | None: + """Cosmetic per-tool emoji for the ``tool.start`` frame. + + Resolved via hermes' own display layer (``agent.display.get_tool_emoji``): + active-skin ``tool_emojis`` overrides first, then the tool registry's + per-tool ``emoji`` field β€” so icons track the user's hermes theme and + new/plugin tools get their registered glyph for free. Returns ``None`` + when the tool is unknown (or the import fails) so the frame omits the + field and the app falls back to its own default glyph. + """ + try: + from agent.display import get_tool_emoji + + return get_tool_emoji(tool_name, default="") or None + except Exception: + return None + + # --------------------------------------------------------------------------- # Runtime-metadata footer (post_api_request hook) # @@ -1569,6 +1587,7 @@ class AndroidAdapter(BasePlatformAdapter): name, preview=preview, args=args, + emoji=_tool_emoji(name), thread_id=thread_id, ), ) diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index 3b58107..e82fe47 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -423,12 +423,15 @@ def tool_start( thread_id: str | None = None, preview: str | None = None, args: dict[str, Any] | None = None, + emoji: str | None = None, ) -> Frame: payload: dict[str, Any] = {"index": index, "name": name} if preview: payload["preview"] = preview if args: payload["args"] = args + if emoji: + payload["emoji"] = emoji return Frame( type=TYPE_TOOL_START, chat_id=chat_id, diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index 7edd6e6..020f7f6 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -2285,6 +2285,67 @@ def test_parse_tool_line_or_block_verbose(plugin): assert name2 == "web_search" and preview2 == "x" and args2 is None +def test_tool_start_frame_emoji_field(plugin): + """``tool.start`` carries the cosmetic emoji when given, omits it when + None (the app then falls back to its own default glyph).""" + pf = plugin.protocol.tool_start + f = pf("android:default", 3, "terminal", emoji="πŸ’»") + assert f.payload["emoji"] == "πŸ’»" + f2 = pf("android:default", 3, "terminal") + assert "emoji" not in f2.payload + f3 = pf("android:default", 3, "terminal", emoji=None) + assert "emoji" not in f3.payload + + +def test_tool_emoji_resolves_via_hermes_display(plugin, monkeypatch): + """``_tool_emoji`` delegates to hermes' ``get_tool_emoji`` (skin overrides + -> registry) and maps unknown tools / failures to None (field omitted).""" + a = plugin.adapter + seen = {} + + def fake_get_tool_emoji(name, default="⚑"): + seen[name] = default + return {"terminal": "πŸ’»", "read_file": "πŸ“–"}.get(name, default) + + monkeypatch.setattr("agent.display.get_tool_emoji", fake_get_tool_emoji) + assert a._tool_emoji("terminal") == "πŸ’»" + assert a._tool_emoji("read_file") == "πŸ“–" + # Unknown tool: the adapter asks for an empty default and drops it. + assert a._tool_emoji("no_such_tool") is None + assert seen["no_such_tool"] == "" + # A display-layer failure degrades to None, never a crash. + def boom(name, default="⚑"): + raise RuntimeError("no display") + + monkeypatch.setattr("agent.display.get_tool_emoji", boom) + assert a._tool_emoji("terminal") is None + + +@pytest.mark.asyncio +async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkeypatch): + """A tool-progress line emits ``tool.start`` with the per-tool emoji + resolved from hermes' display layer.""" + ws, _ = ws_client + monkeypatch.setattr( + "agent.display.get_tool_emoji", + lambda name, default="⚑": "πŸ’»" if name == "terminal" else default, + ) + res = await adapter.send('android:default', 'πŸ’» terminal: "ls -la"') + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "tool.start") + payload = frames[-1]["payload"] + assert payload["name"] == "terminal" + assert payload["emoji"] == "πŸ’»" + # Unknown tool -> field omitted (app falls back to its default glyph). + monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚑": default) + res2 = await adapter.send('android:default', 'πŸ”§ patch: "x"') + assert res2.success + frames2 = await recv_until( + ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch" + ) + assert "emoji" not in frames2[-1]["payload"] + + # ── Slash-command catalog (the app's "/" drawer) ──────────────────────────