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>
6 files changed,
+115
−22
changelog/2026-07-08-reminders-just-type.md+1 −0test/apps.test.mjs+50 −1web/static/apps.js+30 −13web/static/apps/README.md+12 −5web/static/apps/chat.js+11 −1web/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 |