eleven / messenger Public

Reminders: just type to set a reminder (default command, second adopter) (#388)

* web: Reminders adopts the default command — typing sets a reminder

Plain text in the Reminders chat runs /remind: "call mum in 10 minutes"
just sets it. Two framework refinements fell out of this second
adopter (the parsing pressure-test):

- defaultCommand may name any of the app's commands, not only a local
  one — Reminders' global /remind IS its in-thread grammar, so no
  local twin is needed. commandCatalog leads an app's own thread with
  its globals when it has no locals (so /remind still heads its own
  typeahead) instead of dropping them.
- In-thread replies are suppressed only when they carry a `short`
  (transient confirmations); replies without one — content and errors —
  always render. That lets Reminders push back "When should I remind
  you?" on timeless text, and gives bare /add its usage line in-thread.

Placeholder "Remind me… e.g. call mum in 2h", chatIntro reworded to
"just type", README updated. Native (Mac spec row + scheduling port)
follows in its own PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: a rejected default command restores the typed draft

Review finding: routing plain text to /remind wiped the composer even
when the command pushed back with no reminder created. A reply without
a `short` (a push-back, not a confirmation) now puts the typed text
back so it can be fixed — matching the native .rejected path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 42a754ddf1e642338b7bba969908b1ea1819cf95 parent ad7dbd8
6 files changed, +115 −22
  • changelog/2026-07-08-reminders-just-type.md +1 −0
  • test/apps.test.mjs +50 −1
  • web/static/apps.js +30 −13
  • web/static/apps/README.md +12 −5
  • web/static/apps/chat.js +11 −1
  • web/static/apps/remind.js +11 −2
diff --git a/changelog/2026-07-08-reminders-just-type.md b/changelog/2026-07-08-reminders-just-type.md
new file mode 100644
index 0000000..3a03cdc
--- /dev/null
+++ b/changelog/2026-07-08-reminders-just-type.md
@@ -0,0 +1 @@
+🤖 Reminders works the way To-do now does: open your Reminders chat and just type what and when — "call mum in 10 minutes", "standup at 9:00" — no /remind needed. Forget the time and it'll ask "When should I remind you?" instead of staying silent. (/remind still works from any other conversation.)
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index e09aaf6..31426db 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -206,7 +206,7 @@ await test("/help is answered privately (client-side); other bot commands pass t
// todo's /add is `local: true` — it exists only inside the To-do thread. Stub the
// thread-owner lookup the way shell.js injects its threadApp map.
-setThreadOwner((id) => (id === "todoThread" ? "todo" : null));
+setThreadOwner((id) => (id === "todoThread" ? "todo" : id === "remindThread" ? "remind" : null));
await test("a local command resolves only in its app's own thread", () => {
const inThread = parseCommand("/add buy milk", "todoThread");
@@ -288,6 +288,55 @@ await test("plain text through the default command becomes a to-do (the composer
assert.equal(sent[0].event.text, "buy milk");
});
+// ---- Reminders: a GLOBAL verb as the default command ----------------------
+
+await test("Reminders' default is its global /remind (no local twin needed)", () => {
+ const dc = defaultCommandFor("remindThread");
+ assert.ok(dc);
+ assert.equal(dc.name, "/remind");
+ assert.equal(dc.app, remindApp);
+ assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing");
+});
+
+await test("an app with no locals leads its own thread's catalog with its globals", () => {
+ const here = commandCatalog("remindThread");
+ assert.equal(here[0].name, "/remind", "the global verb IS the in-thread verb — it leads");
+ assert.equal(here.filter((c) => c.name === "/remind").length, 1, "listed once, not repeated");
+ assert.ok(here.some((c) => c.name === "/todo"), "other apps' globals stay");
+ assert.ok(!here.some((c) => c.name === "/add"), "another app's LOCAL never leaks in");
+});
+
+await test("plain text with a time becomes a reminder; timeless text gets asked 'when?'", async () => {
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ setAppThreadResolver(async (name) => (name === "remind" ? "remindThread" : "todoThread"));
+ const dc = defaultCommandFor("remindThread");
+ // "call mum in 10 minutes" → remind.add in this thread, and the confirmation
+ // (a `short`-carrying reply) is suppressed in-thread — the action line suffices.
+ assert.equal(runCommand(dc.name + " " + "call mum in 10 minutes", "remindThread", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1);
+ assert.equal(sent.find((e) => e.event.type === "remind.add").event.text, "call mum");
+ assert.equal(replies.length, 0, "in-thread confirmation is suppressed");
+ // No parseable time → the command pushes back, and THAT reply (no `short`)
+ // renders even in-thread: an error is the command's only feedback.
+ assert.equal(runCommand(dc.name + " " + "call mum", "remindThread", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(sent.filter((e) => e.event.type === "remind.add").length, 1, "nothing added");
+ assert.equal(replies.length, 1);
+ assert.match(replies[0].text, /When should I remind you/);
+ assert.equal(replies[0].sender.name, "Reminders");
+ setAppThreadResolver(async () => "todoThread"); // restore for later tests
+});
+
+await test("bare /add in the To-do thread shows its usage line (error replies render in-thread)", () => {
+ const replies = [];
+ assert.equal(runCommand("/add", "todoThread", (r) => replies.push(r)), true);
+ assert.equal(replies.length, 1);
+ assert.match(replies[0].text, /Usage: \/add/);
+});
+
// ---- type ownership (derived from each app's events map) ----------------
await test("isAppType recognises todo events, not chat", () => {
diff --git a/web/static/apps.js b/web/static/apps.js
index 0318f23..39a8ffb 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -96,11 +96,13 @@ injectStyles(
// `local: true` scopes a command to the app's OWN thread: it resolves
// (and shows in the typeahead) only there, never globally — the
// in-thread verb (/add) next to the global routing verb (/todo).
-// defaultCommand — the name of a local command that plain, non-slash text
-// typed into the app's thread runs (e.g. todo's "/add", so typing
-// "buy milk" just adds it). Only honoured while NO bot member is in
-// the thread — with an LLM present, plain text stays a real message
-// for it to answer (see defaultCommandFor).
+// defaultCommand — the name of one of the app's commands that plain,
+// non-slash text typed into the app's thread runs — usually a local
+// verb (todo's "/add", so typing "buy milk" just adds it), but an app
+// whose global verb IS its in-thread grammar names that instead
+// (remind's "/remind"). Only honoured while NO bot member is in the
+// thread — with an LLM present, plain text stays a real message for
+// it to answer (see defaultCommandFor).
// render, styles (styles injected once → self-contained)
// snapshot: false — opt out of state snapshots (for an app whose state IS the
// log, like chat, where snapshotting would duplicate the whole thread)
@@ -903,7 +905,7 @@ export function parseCommand(text, threadId) {
return { name, args, app: commandOwner.get(name) || null, local: false };
}
-// defaultCommandFor: the local command that plain (non-slash) text typed into
+// defaultCommandFor: the command that plain (non-slash) text typed into
// `threadId` should run — { app, name, spec } — or null when the thread isn't
// an app-thread, the app declares no defaultCommand, or a bot member is present
// (an LLM answering the thread makes plain text a real message again, so the
@@ -1093,12 +1095,14 @@ export function runCommand(text, activeThreadId, respond) {
}
if (!target) return;
const ctx = makeCtx(target, app);
- // ctx.reply is the private "only visible to you" confirmation, rendered where
- // you TYPED. Skip it when you're already IN the app's thread: there the event
- // itself lands as a visible action line in the history, so a separate ephemeral
- // reply would be redundant. From another chat, it confirms where the action went.
+ // ctx.reply is the private "only visible to you" reply, rendered where you
+ // TYPED. When you're already IN the app's thread, CONFIRMATIONS are skipped
+ // — the event itself lands as a visible action line, so echoing it would be
+ // redundant — but a reply with no `short` is content or an error ("when
+ // should I remind you?"), the command's only feedback, so it always shows.
+ // "Has a short" == "is a transient confirmation" (see noticeCalmsDown).
ctx.reply = (r) => {
- if (target === activeThreadId) return;
+ if (target === activeThreadId && r && r.short) return;
reply({ sender, ...r });
};
await app.commands[cmd.name].run(args, ctx);
@@ -1116,13 +1120,26 @@ export function runCommand(text, activeThreadId, respond) {
export function commandCatalog(threadId) {
const owner = threadId !== undefined ? threadOwnerApp(threadId) : null;
const out = [];
+ let ownerHasLocals = false;
if (owner) {
for (const [name, c] of Object.entries(owner.commands)) {
- if (c.local) out.push({ name, desc: c.desc, args: c.args, bot: false });
+ if (!c.local) continue;
+ ownerHasLocals = true;
+ out.push({ name, desc: c.desc, args: c.args, bot: false });
+ }
+ // No locals → the app's global verbs ARE its in-thread verbs (Reminders'
+ // /remind), so they lead here instead of dropping out.
+ if (!ownerHasLocals) {
+ for (const [name, c] of Object.entries(owner.commands)) {
+ out.push({ name, desc: c.desc, args: c.args, bot: false });
+ }
}
}
for (const [name, app] of commandOwner.entries()) {
- if (owner && app === owner) continue; // its global aliases are noise in its own thread
+ // In its own thread the app already led with its verbs above: locals
+ // supersede its global aliases (/todo duplicates /add), and lead-listed
+ // globals must not repeat.
+ if (owner && app === owner) continue;
let desc = (app.commands[name] && app.commands[name].desc) || "";
// In a group, say WHERE a group-capable app's command will land: the chat's
// shared instance if the app is added here, else your personal one — with
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 7a9c16f..89f8eea 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -125,7 +125,7 @@ That's a whole app. Everything below is reference detail and the optional hooks.
| `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. |
| `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. |
| `commands` | | `{ "/cmd": (args, ctx) => void }`. Slash commands. The richer form `{ run, desc, args, local }` feeds the `/` typeahead; `local: true` scopes a command to the app's **own thread** (see "Local & default commands"). |
-| `defaultCommand` | | The name of a `local` command that plain, non-slash text typed into the app's thread runs (To-do's `"/add"`). Only honoured while no bot member is in the thread. |
+| `defaultCommand` | | The name of one of the app's commands that plain, non-slash text typed into the app's thread runs — a local verb (To-do's `"/add"`) or, when the global verb IS the in-thread grammar, that global (Reminders' `"/remind"`). Only honoured while no bot member is in the thread. |
| `composerPlaceholder` | | The composer placeholder shown in the app's thread while `defaultCommand` is active (e.g. `"Add a to-do…"`) — it's how the implicit routing announces itself; set it whenever you set `defaultCommand`. |
| `render` | | `(state, el, ctx) => void`. Draws the surface. |
| `messageAction`| | `(msg) => spec \| null`. Adds an item to the chat message menu. |
@@ -235,12 +235,19 @@ that's `/todo`, the *routing* verb. Mark a command `local: true` and it becomes
**in-thread** verb instead: it resolves — and appears in the typeahead — only
inside the app's own thread, never elsewhere, so short names like `/add` can't
collide across apps. In its own thread the local commands lead the typeahead and
-the app's global aliases drop out as redundant.
+the app's global aliases drop out as redundant. An app with **no** local commands
+leads with its globals instead — Reminders' `/remind` heads its own thread's list
+(the global verb is the in-thread verb; nothing supersedes it).
-`defaultCommand` names a local command that **plain text runs**: with no bot
-member in the thread, typing `buy milk` into To-do's chat is `/add buy milk` —
+`defaultCommand` names one of the app's commands that **plain text runs**: with
+no bot member in the thread, typing `buy milk` into To-do's chat is `/add buy
+milk`, and typing `call mum in 2h` into Reminders is `/remind call mum in 2h` —
because sending a chat message no one can answer is a dead end, the message
-becomes the action instead. Three rules keep this honest:
+becomes the action instead. The command can push back: Reminders replies "When
+should I remind you?" for text with no parseable time. (In-thread, replies
+carrying a `short` — transient confirmations — are suppressed, since the event's
+action line is feedback enough; replies without one, content and errors, always
+render.) Three rules keep this honest:
- **Advertise it.** Set `composerPlaceholder` (and write your `chatIntro` to
say "just type") — implicit routing a user can't see is magic, not delight.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index a87677d..121b1ab 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -2961,7 +2961,17 @@ function buildSkeleton(el, ctx) {
if (!editing && !replyTo && text && text[0] !== "/" && !pending.length) {
const dc = defaultCmd();
if (dc) {
- runCommand(dc.name + " " + text, ctx.threadId, (r) => addLocalNotice(ctx, r));
+ const typed = text;
+ runCommand(dc.name + " " + typed, ctx.threadId, (r) => {
+ addLocalNotice(ctx, r);
+ // A reply with no `short` is the command PUSHING BACK ("when should
+ // I remind you?"), not a confirmation — put the typed text back so
+ // it can be fixed, not retyped (matches the native .rejected path).
+ if (r && !r.short && !input.value) {
+ input.value = typed;
+ refreshSend();
+ }
+ });
resetInput();
return;
}
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index a330ed8..5d038b1 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -199,7 +199,11 @@ export const app = defineApp({
args: "<what> <when>",
run: (args, ctx) => {
const p = parseReminder(args);
- if (!p) return ctx.reply({ text: "Try: /remind me in 10 minutes to call mum (or /remind call mum at 18:30)" });
+ // No parseable time → ask for one. Worded for BOTH entry paths — typed
+ // as /remind anywhere, or plain text in the Reminders thread (this is
+ // the app's defaultCommand) — and carrying no `short`, so it renders
+ // even in-thread (an error is the command's only feedback there).
+ if (!p) return ctx.reply({ text: "When should I remind you? Try “call mum in 10 minutes”, “standup at 9:00”, or “renew passport | 2026-08-01”." });
addReminder(ctx, p.text, p.when);
// `short` is what the reply collapses to once it calms down (~30s): a terse
// "Reminders added “…”" one-liner, vs. the loud "I'll remind you: …" card.
@@ -207,6 +211,11 @@ export const app = defineApp({
},
},
},
+ // Plain text typed into the Reminders chat IS a /remind — "what + when" is
+ // this app's whole grammar, so the global verb doubles as the default (no
+ // separate local /add needed). Missing time → the usage reply above asks.
+ defaultCommand: "/remind",
+ composerPlaceholder: "Remind me… e.g. call mum in 2h",
// The bot's chat-view line for one event: an app-thread opens as a conversation
// with its bot (Change 3), so its events read as messages. null = no line.
@@ -214,7 +223,7 @@ export const app = defineApp({
// a message in the Reminders thread (with a sidebar unread + push when you're
// elsewhere), which is the whole point of the DM model.
chatIntro:
- "This is the start of your chat history with Reminders. Type /remind me in 10 minutes to Something — e.g. /remind me in 10 minutes to call mum. You can see and manage your reminders in the Interface view.",
+ "This is the start of your chat history with Reminders. Just type what and when — e.g. call mum in 10 minutes, or standup at 9:00. You can see and manage your reminders in the Interface view.",
chatMessage(e, opts) {
// Firing is the bot messaging YOU (a bubble); setting one is an action you