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>
6 files changed,
+67
−3
changelog/2026-07-06-me-command.md+1 −0test/apps.test.mjs+29 −0web/static/apps.js+14 −0web/static/apps/chat.js+17 −1web/static/shell.js+3 −1web/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) { |