eleven / messenger Public

web: local + default commands โ€” typing into To-do just adds a to-do (#363)

Commands can now be scoped `local: true` (they exist only inside the
owning app's thread โ€” /add in To-do) and an app can name one as its
defaultCommand: with no bot member present, plain text typed into the
app's chat runs it instead of sending a message nobody reads. The
composer advertises the routing (placeholder "Add a to-doโ€ฆ"), hides its
attach affordances in text-only default-command threads, and the "/"
typeahead turns contextual (local commands lead in their thread, the
app's global alias drops out, bot commands stay bot-gated).

iOS mirrors the rule natively: AppDefaultCommand (LChatCore) +
ChatFeedModel.sendAppEvent route NativeChatScreen drafts into todo.add,
with drift-gate tests parsing todo.js so web and native can't diverge.
Mac intentionally untouched (in flight on another branch).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cec9db49905dbb09100057d9136dd3ef8aaf6fd4 parent a6cdaae
12 files changed, +462 −51
  • changelog/2026-07-07-todo-just-type.md +1 −0
  • ios/LChat/Views/NativeChatScreen.swift +36 −14
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift +45 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +31 −0
  • ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift +80 −0
  • test/apps.test.mjs +89 −0
  • web/static/apps.js +90 −22
  • web/static/apps/README.md +31 −1
  • web/static/apps/chat.js +28 −3
  • web/static/apps/todo.js +25 −9
  • web/static/shell.js +5 −1
diff --git a/changelog/2026-07-07-todo-just-type.md b/changelog/2026-07-07-todo-just-type.md
new file mode 100644
index 0000000..42a2eae
--- /dev/null
+++ b/changelog/2026-07-07-todo-just-type.md
@@ -0,0 +1 @@
+๐Ÿค– Adding a to-do is now as easy as typing it: open your To-do chat and just write "buy milk" โ€” no /todo needed, it goes straight onto your list. The message box says so ("Add a to-doโ€ฆ"), and typing / in there now shows the commands that actually work in that chat. (/todo still works from any other conversation.)
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index e062471..1751f31 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -97,6 +97,13 @@ struct NativeChatScreen: View {
return store.roster.contains { members.contains($0.user_id) && $0.isAgent }
}
+ // The app-thread's default command (To-do: plain text adds a to-do) โ€” the
+ // web's defaultCommandFor, so both composers route identically. Nil for a
+ // normal chat, an app with no default, or once a bot member is present.
+ private var defaultCommand: AppDefaultCommand.Spec? {
+ AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot)
+ }
+
// "2 members, 1 bot, 2 AI agents" โ€” the web group header's subtitle.
private var memberStats: String? {
guard isGroup else { return nil }
@@ -638,27 +645,33 @@ struct NativeChatScreen: View {
// (GrowingTextView), and it shares one glass capsule with the send button.
private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) {
- Menu {
- Button { showPhotoPicker = true } label: {
- Label("Photo or Video", systemImage: "photo")
- }
- Button { showFilePicker = true } label: {
- Label("File", systemImage: "doc")
+ // A default-command thread is text-only โ€” with no one to read a
+ // file, an attachment is a dead end, so the affordance goes (the
+ // web composer hides its attach button the same way).
+ if defaultCommand == nil {
+ Menu {
+ Button { showPhotoPicker = true } label: {
+ Label("Photo or Video", systemImage: "photo")
+ }
+ Button { showFilePicker = true } label: {
+ Label("File", systemImage: "doc")
+ }
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 18, weight: .medium))
+ .frame(width: 36, height: 36)
+ .floatingGlass(in: Circle())
}
- } label: {
- Image(systemName: "plus")
- .font(.system(size: 18, weight: .medium))
- .frame(width: 36, height: 36)
- .floatingGlass(in: Circle())
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
HStack(alignment: .bottom, spacing: 6) {
// sendOnReturn: false โ€” touch Enter newlines, the send button
// sends (the shouldSend rule).
ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
- placeholder: "Message", sendOnReturn: false, onSend: sendDraft,
- onPasteImage: { stageImageData($0) },
+ placeholder: defaultCommand?.placeholder ?? "Message",
+ sendOnReturn: false, onSend: sendDraft,
+ onPasteImage: { if defaultCommand == nil { stageImageData($0) } },
onEditLast: {
if let last = model.messages.last(where: { $0.mine && !$0.action }) {
startEdit(last)
@@ -733,6 +746,15 @@ struct NativeChatScreen: View {
break
}
}
+ // Default-command thread (To-do): plain text โ€” or its own local verb,
+ // "/add buy milk" โ€” becomes the app event instead of a chat message
+ // nobody reads. Mirrors the web composer + apps.js defaultCommandFor;
+ // other slash text still falls through to a normal send, like the web.
+ if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
+ let eventText = AppDefaultCommand.route(draft, spec: dc) {
+ if model.sendAppEvent(type: dc.eventType, text: eventText) { draft = "" }
+ return
+ }
if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
staged = []
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
index 98d0ecd..5a7a107 100644
--- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -20,7 +20,7 @@ public enum AppChatProjection {
/// The start-of-history intro each companion app shows above its chat โ€”
/// apps/<id>.js chatIntro, verbatim.
public static let intros: [String: String] = [
- "todo": "This is the start of your chat history with To-do. Type /todo Something to add a to-do โ€” e.g. /todo buy milk. You can see, add, and check off to-dos in the Interface view.",
+ "todo": "This is the start of your chat history with To-do. Just type something to add it as a to-do โ€” e.g. buy milk. You can see, add, and check off to-dos in the Interface view.",
"remind": "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.",
]
diff --git a/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
new file mode 100644
index 0000000..59afa0f
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+// ๐Ÿค– The native mirror of the web's default-command rule (apps.js
+// defaultCommandFor + each app's `defaultCommand`/`composerPlaceholder` in
+// web/static/apps/*.js): in a personal app-thread with NO bot member, plain
+// composer text runs the app's default command instead of sending a chat
+// message nobody reads โ€” typing "buy milk" into To-do just adds it. With a bot
+// present the rule steps aside (plain text is a real message for the LLM), and
+// slash text never falls through โ€” a leading "/" always means command.
+public enum AppDefaultCommand {
+ public struct Spec: Equatable {
+ public let eventType: String // the app event plain text emits ("todo.add")
+ public let command: String // the local verb it sugar-coats ("/add")
+ public let placeholder: String // the composer's advertisement of the routing
+ }
+
+ // One entry per app that declares a defaultCommand on the web โ€” keep in
+ // sync with web/static/apps/*.js (like AppChatProjection's tables).
+ static let specs: [String: Spec] = [
+ "todo": Spec(eventType: "todo.add", command: "/add", placeholder: "Add a to-doโ€ฆ"),
+ ]
+
+ /// The default-command spec for a thread, or nil when the rule is off:
+ /// not an app-thread, no default declared, or a bot member present.
+ public static func spec(appID: String?, hasBot: Bool) -> Spec? {
+ guard let appID, !hasBot else { return nil }
+ return specs[appID]
+ }
+
+ /// The event text a composer draft should emit under `spec`, or nil to send
+ /// the draft as a normal message. Plain text routes wholesale; the app's own
+ /// local verb ("/add buy milk") routes with the verb stripped; any other
+ /// slash text is left alone (a "/" always means command, never content).
+ public static func route(_ text: String, spec: Spec) -> String? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ if trimmed.first != "/" { return trimmed }
+ if trimmed.lowercased() == spec.command { return nil } // bare verb โ€” nothing to add
+ if trimmed.lowercased().hasPrefix(spec.command + " ") {
+ let args = trimmed.dropFirst(spec.command.count).trimmingCharacters(in: .whitespaces)
+ return args.isEmpty ? nil : args
+ }
+ return nil
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 9c8cb32..a420900 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -332,6 +332,37 @@ public final class ChatFeedModel: ObservableObject {
var replyTo: ReplyRef?
}
+ // A typed mini-app event (todo.add, โ€ฆ) โ€” the web's ctx.emit envelope: the
+ // event fields plus the transport-stamped name/ts/uid. The id is the app
+ // entity's stable identity (test/event-vectors/todo.json pins the shape).
+ private struct AppEventOut: Encodable {
+ let type: String
+ let id: String
+ let text: String
+ let name: String
+ let ts: Int64
+ var uid: String?
+ }
+
+ /// Emit a mini-app event into this thread (the default-command path: plain
+ /// text typed into To-do becomes todo.add). Encrypted + sent like any
+ /// message; the echo folds back through the app projection, so the action
+ /// line appears exactly as it would from the web.
+ @discardableResult
+ public func sendAppEvent(type: String, text: String) -> Bool {
+ guard !text.isEmpty, let key, let socket else { return false }
+ var payload = AppEventOut(type: type, id: UUID().uuidString, text: text,
+ name: instance.screenName,
+ ts: Int64(Date().timeIntervalSince1970 * 1000))
+ if !myUserId.isEmpty { payload.uid = myUserId }
+ guard let blob = try? key.encryptJSON(payload),
+ let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)),
+ let json = String(data: frame, encoding: .utf8) else { return false }
+ let sent = socket.send(json)
+ if sent { typingBeat(active: false) }
+ return sent
+ }
+
/// Broadcast a /me-style action line ("Paul waves") โ€” the composer builds
/// the full text; here it rides with action:true, like the web's ctx.emit.
@discardableResult
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
new file mode 100644
index 0000000..f581c71
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift
@@ -0,0 +1,80 @@
+import XCTest
+@testable import LChatCore
+
+// ๐Ÿค– Parity + drift gates for the default-command rule (the web's apps.js
+// defaultCommandFor + chat.js composer routing). The drift gates PARSE
+// web/static/apps/todo.js from the repo, so changing the web app's
+// defaultCommand, placeholder, or chatIntro fails here until Swift follows.
+final class AppDefaultCommandTests: XCTestCase {
+ private var repoRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent() // Tests/LChatCoreTests
+ .deletingLastPathComponent() // Tests
+ .deletingLastPathComponent() // LChatCore
+ .deletingLastPathComponent() // ios
+ .deletingLastPathComponent() // repo
+ }
+
+ // The first "โ€ฆ"-quoted value after `key:` in a JS source.
+ private func jsString(_ source: String, key: String) -> String? {
+ guard let re = try? NSRegularExpression(pattern: key + "\\s*:\\s*\\n?\\s*\"([^\"]+)\""),
+ let m = re.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)),
+ let r = Range(m.range(at: 1), in: source) else { return nil }
+ return String(source[r])
+ }
+
+ // ---- gating (apps.js defaultCommandFor) --------------------------------
+
+ func testSpecGating() {
+ XCTAssertNotNil(AppDefaultCommand.spec(appID: "todo", hasBot: false))
+ XCTAssertNil(AppDefaultCommand.spec(appID: "todo", hasBot: true),
+ "an LLM member makes plain text a real message again")
+ XCTAssertNil(AppDefaultCommand.spec(appID: "remind", hasBot: false),
+ "no default declared on the web yet")
+ XCTAssertNil(AppDefaultCommand.spec(appID: nil, hasBot: false))
+ }
+
+ // ---- routing (chat.js composer) ----------------------------------------
+
+ func testPlainTextRoutes() throws {
+ let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
+ XCTAssertEqual(AppDefaultCommand.route("buy milk", spec: dc), "buy milk")
+ XCTAssertEqual(AppDefaultCommand.route(" buy milk ", spec: dc), "buy milk")
+ XCTAssertNil(AppDefaultCommand.route(" ", spec: dc))
+ }
+
+ func testLocalVerbRoutesWithVerbStripped() throws {
+ let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
+ XCTAssertEqual(AppDefaultCommand.route("/add buy milk", spec: dc), "buy milk")
+ XCTAssertEqual(AppDefaultCommand.route("/ADD buy milk", spec: dc), "buy milk")
+ XCTAssertNil(AppDefaultCommand.route("/add", spec: dc), "bare verb โ€” nothing to add")
+ XCTAssertNil(AppDefaultCommand.route("/add ", spec: dc))
+ }
+
+ func testOtherSlashTextNeverRoutes() throws {
+ let dc = try XCTUnwrap(AppDefaultCommand.spec(appID: "todo", hasBot: false))
+ XCTAssertNil(AppDefaultCommand.route("/theme lagoon", spec: dc),
+ "a leading / always means command, never content")
+ XCTAssertNil(AppDefaultCommand.route("/added to cart", spec: dc),
+ "the verb must be its own token, not a prefix")
+ }
+
+ // ---- drift gates vs web/static/apps/todo.js ----------------------------
+
+ func testSpecMatchesTodoJS() throws {
+ let js = try String(contentsOf: repoRoot.appending(path: "web/static/apps/todo.js"),
+ encoding: .utf8)
+ let dc = try XCTUnwrap(AppDefaultCommand.specs["todo"])
+ XCTAssertEqual(jsString(js, key: "defaultCommand"), dc.command,
+ "Swift default command has drifted from todo.js")
+ XCTAssertEqual(jsString(js, key: "composerPlaceholder"), dc.placeholder,
+ "Swift placeholder has drifted from todo.js")
+ }
+
+ func testChatIntroMatchesTodoJS() throws {
+ let js = try String(contentsOf: repoRoot.appending(path: "web/static/apps/todo.js"),
+ encoding: .utf8)
+ XCTAssertEqual(jsString(js, key: "chatIntro"), AppChatProjection.intros["todo"],
+ "native To-do intro has drifted from todo.js chatIntro")
+ }
+}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index eeef063..6ae3875 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -16,6 +16,9 @@ import {
setAppThreadResolver,
setHostBridge,
setCurrentUser,
+ setThreadHasBot,
+ setThreadOwner,
+ defaultCommandFor,
setToast,
messageActionsFor,
notifyLive,
@@ -192,6 +195,92 @@ await test("/help is answered privately (client-side); other bot commands pass t
assert.ok(cat.some((c) => c.name === "/me" && c.bot === false));
});
+// ---- local commands + the default command --------------------------------
+
+// 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));
+
+await test("a local command resolves only in its app's own thread", () => {
+ const inThread = parseCommand("/add buy milk", "todoThread");
+ assert.equal(inThread.name, "/add");
+ assert.equal(inThread.args, "buy milk");
+ assert.equal(inThread.app, todoApp);
+ assert.equal(inThread.local, true);
+ // Elsewhere (or with no thread context) /add is just an unknown verb.
+ assert.equal(parseCommand("/add buy milk", "chatX").app, null);
+ assert.equal(parseCommand("/add buy milk").app, null);
+});
+
+await test("a local command is never in the global catalog, and leads its thread's", () => {
+ const everywhere = commandCatalog();
+ assert.ok(!everywhere.some((c) => c.name === "/add"), "/add stays out of other threads");
+ assert.ok(everywhere.some((c) => c.name === "/todo"));
+ const here = commandCatalog("todoThread");
+ assert.equal(here[0].name, "/add", "local commands lead in the owning thread");
+ // The app's global routing alias is redundant noise in its own thread.
+ assert.ok(!here.some((c) => c.name === "/todo"));
+ // Other apps' global commands still work from anywhere, so they stay listed.
+ assert.ok(here.some((c) => c.name === "/remind"));
+});
+
+await test("runCommand runs a local command in place โ€” no routing, no echo reply", async () => {
+ const sent = [];
+ const replies = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ // Resolver poisoned: a local command must NOT consult it (it targets where you are).
+ setAppThreadResolver(async () => {
+ throw new Error("local commands never route");
+ });
+ assert.equal(runCommand("/add buy milk", "todoThread", (r) => replies.push(r)), true);
+ await tick();
+ setAppThreadResolver(async () => "todoThread"); // restore for later tests
+ assert.equal(sent.length, 1);
+ assert.equal(sent[0].threadId, "todoThread");
+ assert.equal(sent[0].event.type, "todo.add");
+ assert.equal(sent[0].event.text, "buy milk");
+ // In the app's own thread the event lands as a visible action line, so the
+ // private confirmation is suppressed (same rule as /todo typed in-thread).
+ assert.equal(replies.length, 0);
+});
+
+await test("an unknown command in an app-thread is answered by the app persona", () => {
+ const replies = [];
+ assert.equal(runCommand("/blahblah", "todoThread", (r) => replies.push(r)), true);
+ assert.equal(replies.length, 1);
+ assert.equal(replies[0].sender.name, "To-do");
+ assert.match(replies[0].text, /isn't a command/);
+});
+
+await test("defaultCommandFor: To-do's thread runs /add; other threads have none", () => {
+ const dc = defaultCommandFor("todoThread");
+ assert.ok(dc);
+ assert.equal(dc.name, "/add");
+ assert.equal(dc.app, todoApp);
+ assert.ok(dc.app.composerPlaceholder, "the composer must advertise the routing");
+ assert.equal(defaultCommandFor("chatX"), null);
+});
+
+await test("defaultCommandFor steps aside when a bot member is present", () => {
+ // With an LLM in the thread, plain text is a real message for it to answer.
+ setThreadHasBot((id) => id === "todoThread");
+ assert.equal(defaultCommandFor("todoThread"), null);
+ setThreadHasBot(null);
+ assert.ok(defaultCommandFor("todoThread"));
+});
+
+await test("plain text through the default command becomes a to-do (the composer flow)", async () => {
+ const sent = [];
+ setEmitTransport((threadId, event) => (sent.push({ threadId, event }), Promise.resolve(true)));
+ // What chat.js does for non-slash text in a default-command thread:
+ const dc = defaultCommandFor("todoThread");
+ assert.equal(runCommand(dc.name + " " + "buy milk", "todoThread"), true);
+ await tick();
+ assert.equal(sent.length, 1);
+ assert.equal(sent[0].event.type, "todo.add");
+ assert.equal(sent[0].event.text, "buy milk");
+});
+
// ---- 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 4f19efe..1226f6d 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -92,7 +92,15 @@ injectStyles(
// initialState โ€” () => the empty state (seeded on the first event)
// commands โ€” slash commands the app owns, as
// { "/todo": (args, ctx) => {โ€ฆ} } or, to feed the "/" typeahead,
-// { "/todo": { run, desc: "Add a to-do", args: "<task>" } }
+// { "/todo": { run, desc: "Add a to-do", args: "<task>" } }.
+// `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).
// 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)
@@ -117,7 +125,10 @@ export function defineApp(spec) {
app.reduce = makeReducer({ ...(app.hostEvents || {}), ...(app.events || {}) }, app.initialState);
apps.push(app);
for (const t of app.types) typeOwner.set(t, app);
- for (const name of Object.keys(app.commands)) commandOwner.set(name, app);
+ // Local commands stay off the global registry โ€” they resolve per-thread, via
+ // the owning app (see parseCommand), so "/add" can mean different things in
+ // different app-threads and never collides across apps.
+ for (const [name, c] of Object.entries(app.commands)) if (!c.local) commandOwner.set(name, app);
injectStyles(app.name, app.styles);
return app;
}
@@ -125,7 +136,10 @@ export function defineApp(spec) {
function normalizeCommands(cmds) {
const out = {};
for (const [name, v] of Object.entries(cmds || {})) {
- out[name] = typeof v === "function" ? { run: v, desc: "", args: "" } : { run: v.run, desc: v.desc || "", args: v.args || "" };
+ out[name] =
+ typeof v === "function"
+ ? { run: v, desc: "", args: "", local: false }
+ : { run: v.run, desc: v.desc || "", args: v.args || "", local: !!v.local };
}
return out;
}
@@ -552,6 +566,18 @@ let threadHasBotFn = null;
export function setThreadHasBot(fn) {
threadHasBotFn = fn;
}
+// Which app OWNS a thread (a kind=="self" app-thread's app name, else null) โ€”
+// shell.js injects its threadApp lookup. This is what scopes local commands and
+// the default command to the app's own thread. Absent โ†’ no thread is app-owned.
+let threadOwnerFn = null;
+export function setThreadOwner(fn) {
+ threadOwnerFn = fn;
+}
+// The app object owning `threadId`, or null (not an app-thread / no injection).
+function threadOwnerApp(threadId) {
+ const name = threadOwnerFn ? threadOwnerFn(threadId) : null;
+ return name ? appByName(name) : null;
+}
// Open a thread โ€” used ONLY by an action chip a command offers, never by the
// command itself (a command answers in place; leaving the context is the user's
// tap โ€” see CLAUDE.md). shell.js injects openThread.
@@ -726,7 +752,7 @@ export function registerFloorCommand(name, spec) {
// them, so /help never advertises a command that would do nothing here.
function helpNotice(threadId) {
const hasBot = threadHasBotFn ? !!threadHasBotFn(threadId) : false;
- const lines = commandCatalog()
+ const lines = commandCatalog(threadId)
.filter((c) => !c.bot || hasBot)
.map((c) => `${c.name}${c.args ? " " + c.args : ""} โ€” ${c.desc}`);
const text = lines.length ? "Commands you can use:\n" + lines.join("\n") : "No commands are available here yet.";
@@ -734,17 +760,39 @@ function helpNotice(threadId) {
}
// parseCommand pulls the command out of "/todo buy milk" โ†’ {name:"/todo",
-// args:"buy milk", app}. `app` is the owning app, or null when the text is
-// "/word" but no app owns it (a FAILED command โ€” the caller answers with an
+// args:"buy milk", app, local}. `app` is the owning app, or null when the text
+// is "/word" but no app owns it (a FAILED command โ€” the caller answers with an
// "unknown command" reply rather than broadcasting it). Returns null only when
// the text isn't a command attempt at all: no leading "/", or a bare "/" / "/ โ€ฆ"
-// (a lone slash is punctuation, sent as normal text).
-export function parseCommand(text) {
+// (a lone slash is punctuation, sent as normal text). Pass the thread the text
+// was typed in as `threadId` and the owning app's LOCAL commands resolve there
+// first ("/add" in the To-do thread) โ€” without it, only global commands match.
+export function parseCommand(text, threadId) {
if (!text || text[0] !== "/") return null;
const m = /^\/(\S+)/.exec(text);
if (!m) return null; // "/" alone or "/ โ€ฆ" โ†’ literal text, not a command
const name = "/" + m[1];
- return { name, args: text.slice(name.length).trim(), app: commandOwner.get(name) || null };
+ const args = text.slice(name.length).trim();
+ const owner = threadId !== undefined ? threadOwnerApp(threadId) : null;
+ if (owner && owner.commands[name] && owner.commands[name].local) {
+ return { name, args, app: owner, local: true };
+ }
+ return { name, args, app: commandOwner.get(name) || null, local: false };
+}
+
+// defaultCommandFor: the local 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
+// implicit routing steps aside). This is the whole "typing into To-do adds a
+// to-do" decision, kept pure so web, tests, and native mirror one rule.
+export function defaultCommandFor(threadId) {
+ const app = threadOwnerApp(threadId);
+ if (!app || !app.defaultCommand) return null;
+ const spec = app.commands[app.defaultCommand];
+ if (!spec || !spec.run) return null;
+ if (threadHasBotFn && threadHasBotFn(threadId)) return null;
+ return { app, name: app.defaultCommand, spec };
}
// /me is unlike every other command: it BROADCASTS a real message (an
@@ -768,7 +816,7 @@ export function parseMeCommand(text) {
// handler answers with ctx.reply(...); it never navigates โ€” moving out of
// context is only ever an action chip the user taps (see CLAUDE.md).
export function runCommand(text, activeThreadId, respond) {
- const cmd = parseCommand(text);
+ const cmd = parseCommand(text, activeThreadId);
if (!cmd) return false; // not a command attempt โ†’ let the composer send it
const reply = (r) => {
try {
@@ -793,13 +841,19 @@ export function runCommand(text, activeThreadId, respond) {
// Any other bot command (/theme, โ€ฆ) is SENT so the bot answers it: return false
// to let the composer post it. Only a truly unknown "/word" is caught + answered.
if (BOT_COMMANDS[cmd.name]) return false;
- reply({ sender: defaultBotFn ? defaultBotFn() : null, text: `${cmd.name} isn't a command. Type / to see what's available.` });
+ // In an app's own thread the app answers for itself; elsewhere Eleven does.
+ const owner = threadOwnerApp(activeThreadId);
+ const sender = owner ? { name: owner.title || owner.name, glyph: owner.glyph, kind: "app" } : defaultBotFn ? defaultBotFn() : null;
+ reply({ sender, text: `${cmd.name} isn't a command. Type / to see what's available.` });
return true; // swallow โ€” a failed command is answered, not broadcast
}
const app = cmd.app;
(async () => {
let target = activeThreadId;
- if (app.scope === "personal") target = resolveAppThread ? await resolveAppThread(app.name) : null;
+ // A LOCAL command only exists in the thread it was typed in โ€” it targets that
+ // thread directly, no routing. A global personal command routes to its own
+ // app-thread from anywhere.
+ if (!cmd.local && app.scope === "personal") target = resolveAppThread ? await resolveAppThread(app.name) : null;
if (!target) return;
const ctx = makeCtx(target, app);
// ctx.reply is the private "only visible to you" confirmation, rendered where
@@ -815,16 +869,30 @@ export function runCommand(text, activeThreadId, respond) {
return true;
}
-// The registered commands as { name, desc, args, bot } โ€” feeds the "/" typeahead.
-// App commands (client-run) come first, then bot commands (sent to the bot). The
-// caller may hide `bot` ones in threads with no bot (see shell.js typeaheadItems).
-export function commandCatalog() {
- const out = [...commandOwner.entries()].map(([name, app]) => ({
- name,
- desc: (app.commands[name] && app.commands[name].desc) || "",
- args: (app.commands[name] && app.commands[name].args) || "",
- bot: false,
- }));
+// The registered commands as { name, desc, args, bot } โ€” feeds the "/" typeahead
+// and /help. Pass the thread being typed in as `threadId` and the list becomes
+// contextual: inside an app's own thread its LOCAL commands lead (they're the
+// verbs that act right here), and the app's global routing aliases (/todo in the
+// To-do thread) are dropped as redundant. Other apps' global commands stay โ€” they
+// work from anywhere by design. The caller may hide `bot` ones in threads with no
+// bot (see shell.js typeaheadItems).
+export function commandCatalog(threadId) {
+ const owner = threadId !== undefined ? threadOwnerApp(threadId) : null;
+ const out = [];
+ 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 });
+ }
+ }
+ for (const [name, app] of commandOwner.entries()) {
+ if (owner && app === owner) continue; // its global aliases are noise in its own thread
+ out.push({
+ name,
+ desc: (app.commands[name] && app.commands[name].desc) || "",
+ args: (app.commands[name] && app.commands[name].args) || "",
+ bot: false,
+ });
+ }
// 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
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 908aaff..be08460 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -123,7 +123,9 @@ That's a whole app. Everything below is reference detail and the optional hooks.
| `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked โ€” how a service "installs" its companion app. See `docs/connected-services.md`. |
| `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. |
+| `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. |
+| `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. |
| `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. |
@@ -156,6 +158,34 @@ joined) DM shows a waiting card instead of the chat app's pane, but that's a
host-level concern, not a fourth scope. See `docs/mini-apps-architecture.html`
ยง5 for why, and the design question to revisit once the classic feed is retired.
+### Local & default commands
+
+A command is **global** by default: it's in the `/` typeahead of every thread,
+and (for a personal app) routes into your app-thread from wherever you typed it โ€”
+that's `/todo`, the *routing* verb. Mark a command `local: true` and it becomes an
+**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.
+
+`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` โ€”
+because sending a chat message no one can answer is a dead end, the message
+becomes the action instead. 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.
+- **It steps aside for an LLM.** The gate is "no bot member here"
+ (`defaultCommandFor`): if a bot later joins the thread, plain text goes back
+ to being a real message for it to answer, and your commands become things it
+ can invoke on the user's behalf.
+- **Personal app-threads only.** A shared app in a group chat must never eat
+ plain messages โ€” those are real conversation. Slash text never falls through
+ to the default either: a leading `/` always means "command".
+
+A default-command thread is text-only: the composer hides its attach affordances
+(there's no one to read a file), and they return if a bot joins.
+
## The events map & the reducer
Each handler is `(state, event, meta) => newState`. Rules:
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 403782f..a93ad87 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, deliverAnswer, parseMeCommand } from "../apps.js";
+import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor } 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";
@@ -2273,10 +2273,18 @@ function buildSkeleton(el, ctx) {
const field = document.createElement("div");
field.className = "composer-field";
const input = document.createElement("textarea");
- input.placeholder = composerHints.get(ctx.threadId) || "Message";
+ // When plain text runs the app's default command (typing into To-do adds a
+ // to-do โ€” see defaultCommandFor), the placeholder must SAY so: the implicit
+ // routing is only honest if the composer advertises it before the first send.
+ const defaultCmd = () => defaultCommandFor(ctx.threadId);
+ const basePlaceholder = () => {
+ const dc = defaultCmd();
+ return (dc && dc.app.composerPlaceholder) || "Message";
+ };
+ input.placeholder = composerHints.get(ctx.threadId) || basePlaceholder();
refreshComposerHint.set(ctx.threadId, () => {
const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
- input.placeholder = attach || composerHints.get(ctx.threadId) || "Message";
+ input.placeholder = attach || composerHints.get(ctx.threadId) || basePlaceholder();
});
input.rows = 1;
input.autocomplete = "off";
@@ -2289,6 +2297,10 @@ function buildSkeleton(el, ctx) {
'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="6 11 12 5 18 11"></polyline></svg>';
field.append(input, send);
compose.append(attach, fileInput, field);
+ // A default-command thread takes text only โ€” with no one to read a file, an
+ // attachment is a dead end, so the affordance goes (stageFiles gates paste and
+ // drop the same way).
+ attach.hidden = !!defaultCmd();
root.append(surface, feed, float, replyBanner, editBanner, compose);
@@ -2534,6 +2546,7 @@ function buildSkeleton(el, ctx) {
// firing early would send the message without them.
const stageFiles = async (files) => {
if (!host || !host.upload) return;
+ if (defaultCmd()) return; // text-only surface โ€” see the hidden attach button
staging++;
try {
await stageFilesBatch(files);
@@ -2926,6 +2939,18 @@ function buildSkeleton(el, ctx) {
resetInput();
return;
}
+ // Plain text in a default-command thread runs the app's default command
+ // instead of sending a message nobody reads โ€” typing "buy milk" into To-do
+ // just adds it (defaultCommandFor gates this to app-threads with no bot;
+ // slash text never falls through here, a "/" always means command).
+ if (!editing && !replyTo && text && text[0] !== "/" && !pending.length) {
+ const dc = defaultCmd();
+ if (dc) {
+ runCommand(dc.name + " " + text, ctx.threadId, (r) => addLocalNotice(ctx, r));
+ resetInput();
+ return;
+ }
+ }
if (editing) {
// An empty edit just cancels; otherwise only clear once the edit actually
// reaches the server โ€” host.edit resolves false on a down socket, so the
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index 024ca35..da434a5 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -39,19 +39,23 @@ export const app = defineApp({
"todo.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
},
commands: {
+ // The global routing verb: /todo typed in ANY chat files into your app-thread.
"/todo": {
desc: "Add a to-do",
args: "<task>",
- run: (args, ctx) => {
- const text = args.trim();
- if (!text) return ctx.reply({ text: "Usage: /todo <task> โ€” e.g. /todo buy milk" });
- ctx.emit({ type: "todo.add", id: ctx.id(), text });
- // `short` is the terse one-liner the reply calms down to (~30s): "To-do
- // added โ€œโ€ฆโ€", vs. the loud "Added โ€œโ€ฆโ€ to your to-do list." card.
- ctx.reply({ text: `Added โ€œ${text}โ€ to your to-do list.`, short: `added โ€œ${text}โ€`, actions: [{ label: "View to-do", run: ctx.open }] });
- },
+ run: (args, ctx) => addTodo(args, ctx, "/todo"),
+ },
+ // The in-thread verb โ€” and the DEFAULT: plain text typed into the To-do chat
+ // runs /add, so "buy milk" just becomes a to-do (see defaultCommand below).
+ "/add": {
+ desc: "Add a to-do",
+ args: "<task>",
+ local: true,
+ run: (args, ctx) => addTodo(args, ctx, "/add"),
},
},
+ defaultCommand: "/add",
+ composerPlaceholder: "Add a to-doโ€ฆ",
// Adding a to-do reads as an action in the chat history (a /me-style line);
// done/reopen/remove happen in the Interface view and carry no text, so they
@@ -63,7 +67,7 @@ export const app = defineApp({
// The empty-state intro shown the first time you open this bot's chat.
chatIntro:
- "This is the start of your chat history with To-do. Type /todo Something to add a to-do โ€” e.g. /todo buy milk. You can see, add, and check off to-dos in the Interface view.",
+ "This is the start of your chat history with To-do. Just type something to add it as a to-do โ€” e.g. buy milk. You can see, add, and check off to-dos in the Interface view.",
// This app's CSS travels with it โ€” the framework injects it once. It reads the
// theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
@@ -254,6 +258,18 @@ export const app = defineApp({
},
});
+// The one add-a-to-do core both verbs share โ€” /todo from anywhere, /add (and
+// plain typed text, via defaultCommand) in the app's own thread. `verb` only
+// shapes the usage line a bare command gets back.
+function addTodo(args, ctx, verb) {
+ const text = args.trim();
+ if (!text) return ctx.reply({ text: `Usage: ${verb} <task> โ€” e.g. ${verb} buy milk` });
+ ctx.emit({ type: "todo.add", id: ctx.id(), text });
+ // `short` is the terse one-liner the reply calms down to (~30s): "To-do
+ // added โ€œโ€ฆโ€", vs. the loud "Added โ€œโ€ฆโ€ to your to-do list." card.
+ ctx.reply({ text: `Added โ€œ${text}โ€ to your to-do list.`, short: `added โ€œ${text}โ€`, actions: [{ label: "View to-do", run: ctx.open }] });
+}
+
// The completed section's expand state, per app-thread (module scope so it
// survives repaints and re-renders โ€” it's transient UI, not fold state).
const completedOpen = {};
diff --git a/web/static/shell.js b/web/static/shell.js
index ab75e66..59288e0 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -50,6 +50,7 @@ import {
setToast,
setDefaultBot,
setThreadHasBot,
+ setThreadOwner,
setThreadOpener,
setHostBridge,
setSpaceInfo,
@@ -2854,6 +2855,7 @@ setDefaultBot(() => {
// Lets /help list the bot's commands only where a bot can answer them (same gate
// as the "/" typeahead).
setThreadHasBot((id) => threadHasBot(id));
+setThreadOwner((id) => threadApp[id] || null); // scopes local + default commands to an app's own thread
// Space info for Eleven's guided tour: the group name plus the optional
// administrator identity. The admin fields are member-gated, so read /api/group
// with our token; the tour degrades to just the name if it's unset or fails.
@@ -6288,7 +6290,9 @@ function typeaheadItems(tok) {
if (tok.kind === "command") {
const q = tok.query.toLowerCase();
const hasBot = threadHasBot(activeId); // bot commands only where a bot can answer them
- return commandCatalog()
+ // Contextual: inside an app-thread its local commands lead and its global
+ // aliases drop out (commandCatalog handles both when given the thread).
+ return commandCatalog(activeId)
.filter((c) => (!c.bot || hasBot) && c.name.slice(1).toLowerCase().startsWith(q))
.slice(0, 8)
.map((c) => ({ command: c.name, desc: c.desc, args: c.args, insert: c.name + " " }));