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>
12 files changed,
+462
−51
changelog/2026-07-07-todo-just-type.md+1 −0ios/LChat/Views/NativeChatScreen.swift+36 −14ios/LChatCore/Sources/LChatCore/AppChatProjection.swift+1 −1ios/LChatCore/Sources/LChatCore/AppDefaultCommand.swift+45 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+31 −0ios/LChatCore/Tests/LChatCoreTests/AppDefaultCommandTests.swift+80 −0test/apps.test.mjs+89 −0web/static/apps.js+90 −22web/static/apps/README.md+31 −1web/static/apps/chat.js+28 −3web/static/apps/todo.js+25 −9web/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 + " " })); |