eleven / messenger Public

web: add a /me command for IRC-style action lines (#256)

Typing "/me waves" broadcasts a centered, muted action line ("Alice
waves") instead of a chat bubble, reusing the action:true rendering
path that mini-app companions already use. Unlike every other slash
command, /me broadcasts a real message rather than answering privately,
so it's handled directly in the composer rather than through
runCommand.

Also fixes two rough edges the new user-owned action lines exposed:
raw payloads didn't preserve action:true (so editing a /me message
would silently turn it into a normal bubble), and the group "sender:
text" prefix used in push notifications and the sidebar preview would
double up the name on an action line.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam d5caba9fab38393148f1cbd6c444b868436cd9f7 parent 17beacf
6 files changed, +67 −3
  • changelog/2026-07-06-me-command.md +1 −0
  • test/apps.test.mjs +29 −0
  • web/static/apps.js +14 −0
  • web/static/apps/chat.js +17 −1
  • web/static/shell.js +3 −1
  • web/static/sw.js +3 −1
diff --git a/changelog/2026-07-06-me-command.md b/changelog/2026-07-06-me-command.md
new file mode 100644
index 0000000..d780a09
--- /dev/null
+++ b/changelog/2026-07-06-me-command.md
@@ -0,0 +1 @@
+🤖 Added a `/me` command: type `/me waves` and it posts as a centered action line ("Alice waves") instead of a chat bubble, IRC/Slack-style.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index ab7a1db..92dd9d9 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -2,6 +2,7 @@
import assert from "node:assert/strict";
import {
parseCommand,
+ parseMeCommand,
runCommand,
commandCatalog,
isAppType,
@@ -101,6 +102,24 @@ await test("parseCommand flags an unknown /command (app:null) but ignores plain
assert.equal(c.app, null); // unowned verb → a failed command, not chat (caller replies)
});
+await test("parseMeCommand pulls the action out of a /me line", () => {
+ assert.equal(parseMeCommand("/me waves"), "waves");
+ assert.equal(parseMeCommand("/me waves hello "), "waves hello");
+ assert.equal(parseMeCommand("/ME waves"), "waves"); // case-insensitive, like other commands
+});
+
+await test("parseMeCommand allows a bare /me (composer treats it as nothing to say)", () => {
+ assert.equal(parseMeCommand("/me"), "");
+ assert.equal(parseMeCommand("/me "), "");
+});
+
+await test("parseMeCommand ignores non-/me text, including other commands", () => {
+ assert.equal(parseMeCommand("hello"), null);
+ assert.equal(parseMeCommand(""), null);
+ assert.equal(parseMeCommand("/todo buy milk"), null);
+ assert.equal(parseMeCommand("/mean thing"), null); // "/me" must be its own token, not a prefix
+});
+
// ---- command execution → emitted event ----------------------------------
await test("runCommand routes a personal /todo into the app-thread and emits", async () => {
@@ -160,6 +179,10 @@ await test("/help is answered privately (client-side); other bot commands pass t
assert.ok(cat.some((c) => c.name === "/help" && c.bot === false));
assert.ok(cat.some((c) => c.name === "/theme" && c.bot === true));
assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false));
+ // /me is listed for discoverability, but it's never routed through runCommand —
+ // the composer (chat.js) handles it directly via parseMeCommand, since (unlike
+ // every other command) it broadcasts a real message rather than replying privately.
+ assert.ok(cat.some((c) => c.name === "/me" && c.bot === false));
});
// ---- type ownership (derived from each app's events map) ----------------
@@ -729,6 +752,12 @@ await test("one message folds into TWO apps (owner state + chat action bubble)",
assert.equal(chat.msgs[0].action, true, "and it's flagged as an action line");
});
+await test("chat: an action line's raw payload keeps action:true (so editing it doesn't turn it into a bubble)", () => {
+ const reduce = chatApp.reduce;
+ const s = reduce(undefined, { type: "chat.msg", text: "Alice waves", name: "Alice", uid: "u-al", action: true }, { id: 90 });
+ assert.equal(s.msgs[0].raw.action, true, "{ ...raw, text } re-sends action:true on edit");
+});
+
// ---- chat as an app (the lab) --------------------------------------------
// The chat app consumes the EXISTING protocol: original-shaped messages fold as host
// events keyed by server message id, and reactions/edits/deletes are host-only
diff --git a/web/static/apps.js b/web/static/apps.js
index 4b79fc9..ba4db49 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -689,6 +689,17 @@ export function parseCommand(text) {
return { name, args: text.slice(name.length).trim(), app: commandOwner.get(name) || null };
}
+// /me is unlike every other command: it BROADCASTS a real message (an
+// action-style line, `{text, action:true}`) rather than answering privately,
+// so the composer (chat.js) handles it directly, before runCommand ever sees
+// the text. parseMeCommand pulls the action out of "/me waves" → "waves" (or
+// "" for a bare "/me"), or null when the text isn't a /me line at all — kept
+// pure + exported so it's unit-tested without a DOM.
+export function parseMeCommand(text) {
+ const m = /^\/me(?:\s+([\s\S]*))?$/i.exec((text || "").trim());
+ return m ? (m[1] || "").trim() : null;
+}
+
// runCommand executes a slash command, answering IN PLACE via respond(response)
// — a private "only visible to you" reply the caller renders in the CURRENT
// thread's feed (see buildLocalNotice). Returns true synchronously whenever the
@@ -758,6 +769,9 @@ export function commandCatalog() {
}));
// Floor commands (e.g. /tutorial) are client-answered → shown in every thread.
for (const [name, c] of floorCommands) out.push({ name, desc: c.desc, args: c.args, bot: false });
+ // /me is handled by the composer itself (see parseMeCommand) — listed here only
+ // so the "/" typeahead and /help surface it, like the client-answered set.
+ out.push({ name: "/me", desc: "post an action line, e.g. /me waves", args: "<action>", bot: false });
// bot:false for the ones the client answers itself (/help) → the typeahead lists
// them in every thread, not only where a bot is present.
for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: !CLIENT_ANSWERED.has(name) });
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index c309157..ae61cb0 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -22,7 +22,7 @@
//
// This is the DEFAULT chat experience for group/DM threads; a device can
// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
-import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp } from "../apps.js";
+import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, parseMeCommand } from "../apps.js";
import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { emojify } from "../emoji.js";
import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js";
@@ -266,6 +266,7 @@ export const app = defineApp({
if (atts.length) raw.attachments = atts.map((a) => ({ ...a }));
if (e.replyTo) raw.replyTo = { ...e.replyTo };
if (Array.isArray(e.actions) && e.actions.length) raw.actions = e.actions; // helper chips (host-whitelisted at render)
+ if (e.action) raw.action = true; // keeps a /me line rendering as an action line if you edit it
const msgs = [
...s.msgs,
{
@@ -2735,6 +2736,21 @@ function buildSkeleton(el, ctx) {
refreshSend();
stopTypingBeat(); // command / edit / send all clear the field — end the beat now
};
+ // /me is the one command that BROADCASTS a real message — an action-style
+ // line ("Alice waves"), not a bubble — instead of answering privately, so
+ // it's handled here rather than through runCommand (see parseMeCommand).
+ if (!editing) {
+ const action = parseMeCommand(text);
+ if (action !== null) {
+ if (!action) return resetInput(); // bare "/me" — nothing to say
+ ctx.emit({ text: `${ctx.meName || "Someone"} ${action}`, action: true }).then((sent) => {
+ if (!sent) return;
+ pinNext.add(ctx.threadId);
+ resetInput();
+ });
+ return;
+ }
+ }
// A slash command (mini-app command, /help, or an unknown "/foo") is handled
// instead of sending — it answers in place with a private "only visible to you"
// reply that stacks in the feed and persists (addLocalNotice). Skipped while
diff --git a/web/static/shell.js b/web/static/shell.js
index 78bb4cf..3698e9a 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1040,7 +1040,9 @@ function previewText(t, data) {
body = n > 1 ? `📎 ${n} files` : "📎 " + ((atts[0] && atts[0].name) || "File");
}
}
- return t.kind === "group" && data.name ? `${data.name}: ${body}` : body;
+ // An action line ("Alice waves") already names the actor — skip the group
+ // prefix so it doesn't double up ("Alice: Alice waves").
+ return t.kind === "group" && data.name && !data.action ? `${data.name}: ${body}` : body;
}
// Native (Eleven) local notification: inside the Mac wrapper, hand a decrypted
diff --git a/web/static/sw.js b/web/static/sw.js
index cf88d93..af45bfb 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -103,7 +103,9 @@ async function handlePush(event) {
}
title = t.name || sender || "New message";
// In groups, prefix the sender; a DM's title is already the person.
- body = t.kind === "group" && sender ? `${sender}: ${text}` : text;
+ // An action line ("Alice waves") already names the actor — skip the
+ // prefix so it doesn't double up ("Alice: Alice waves").
+ body = t.kind === "group" && sender && !msg.action ? `${sender}: ${text}` : text;
}
}
} catch (e) {