diff --git a/docs/rich-ui-surface-design.md b/docs/rich-ui-surface-design.md index 1cb4802..2c4c30f 100644 --- a/docs/rich-ui-surface-design.md +++ b/docs/rich-ui-surface-design.md @@ -70,13 +70,15 @@ export interface RichMenu { } export interface RichResponse { - text: string; - menu?: RichMenu; + text: string; // verbose fallback (no menu, or menu render fails) + menu?: RichMenu; // optional inline keyboard / blocks + menuCaption?: string; // concise body shown next to the menu (avoids duplicating buttons) } ``` Opinionated constraints: - `text` is mandatory and is the canonical fallback +- `menuCaption` is optional; transports prefer it over `text` when rendering a menu, so commands can keep `text` verbose (lists every option) without polluting the menu view. See `src/transports/types.ts` for the source-of-truth contract. - buttons carry gateway action ids, not platform callback payloads - menu layout is descriptive, not platform-specific - no Telegram HTML, no Slack blocks, no Discord components in gateway code @@ -220,8 +222,9 @@ export interface RichMenu { } export interface RichResponse { - text: string; + text: string; // verbose fallback menu?: RichMenu; + menuCaption?: string; // concise body when menu renders; falls back to `text` when absent } export interface TransportAdapter { diff --git a/src/transports/rich-helpers.ts b/src/transports/rich-helpers.ts index f5919ae..63e883e 100644 --- a/src/transports/rich-helpers.ts +++ b/src/transports/rich-helpers.ts @@ -85,8 +85,9 @@ export function buildSelectableMenu(opts: SelectableMenuOpts): RichResponse { }); } - // ── Text fallback ── - // Mirrors menu information for transports that can't render buttons. + // ── Text fallback (verbose: header + available list + hint) ── + // Used when transports can't render the menu, or for text-only adapters. + // Mirrors all menu information so users still see every option. const optionLines = options.map((o) => { const marker = o.key === current ? " (current)" : ""; return ` \`${o.key}\` → ${o.label}${marker}`; @@ -99,8 +100,17 @@ export function buildSelectableMenu(opts: SelectableMenuOpts): RichResponse { textParts.push("", textHint); } + // ── Menu caption (concise: header + hint only) ── + // Shown next to the keyboard when the menu renders. The buttons + // already convey the option list — don't duplicate. + const captionParts: string[] = [textHeader]; + if (textHint) { + captionParts.push("", textHint); + } + return { text: textParts.join("\n"), + menuCaption: captionParts.join("\n"), menu: { sections: [{ columns, buttons }], }, diff --git a/src/transports/telegram/telegram-adapter.ts b/src/transports/telegram/telegram-adapter.ts index b47b7c0..e236cf3 100644 --- a/src/transports/telegram/telegram-adapter.ts +++ b/src/transports/telegram/telegram-adapter.ts @@ -85,7 +85,10 @@ export class TelegramAdapter implements TransportAdapter { try { // text formatting: response.text is already markdown-ish from commands. // We pass it through markdownToTelegramHtml so bold/code render natively. - const html = markdownToTelegramHtml(response.text); + // When menuCaption is provided, prefer it as the body next to the + // keyboard — commands use this to avoid duplicating buttons in text. + const body = response.menuCaption ?? response.text; + const html = markdownToTelegramHtml(body); await tgAdapter.telegramFetch("sendMessage", { chat_id: chatId, text: html, diff --git a/src/transports/types.ts b/src/transports/types.ts index b4c7c7b..6172e12 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -88,12 +88,25 @@ export interface RichMenu { /** * RichResponse — what a command returns to the gateway. * - * `text` is mandatory and is the canonical fallback. `menu` is optional; - * transports that can't render menus simply post the text. + * - `text` is mandatory and is the canonical text-only fallback. Used by + * transports that can't render menus, or when menu rendering fails. + * - `menu` is optional; transports that can render menus use it. + * - `menuCaption` is optional; when present AND the menu is rendered, + * transports show this *instead of* `text` as the message body next to + * the keyboard. This avoids duplication between the buttons and a + * verbose option list. When the menu can't render, transports fall + * back to `text` (which can be more verbose). + * + * Convention: `text` is for the no-menu world (full info), `menuCaption` + * is for the with-menu world (concise context). Commands that don't care + * about the distinction can just set `text`; transports treat that as + * "use the same body in both cases" by falling back to `text` when + * `menuCaption` is absent. */ export interface RichResponse { text: string; menu?: RichMenu; + menuCaption?: string; } /** diff --git a/test/rich-helpers.test.ts b/test/rich-helpers.test.ts index 21fbc52..505abc2 100644 --- a/test/rich-helpers.test.ts +++ b/test/rich-helpers.test.ts @@ -148,4 +148,29 @@ describe("buildSelectableMenu", () => { // But the sentinel button is still rendered. expect(r.menu!.sections[0].buttons).toHaveLength(1); }); + + it("menuCaption is concise (header + hint only, no Available block)", () => { + // The caption is what shows next to the keyboard — the buttons + // already enumerate the options, so the caption shouldn't repeat + // them. Verbose listing belongs only on the text fallback. + const r = buildSelectableMenu({ + current: "sonnet", + options: [ + { key: "opus", label: "Claude Opus" }, + { key: "sonnet", label: "Claude Sonnet" }, + ], + actionId: "model", + textHeader: "\ud83e\udd16 *Current model:* Claude Sonnet", + textHint: "_Usage:_ `/model sonnet`", + }); + // Text (fallback) DOES list options. + expect(r.text).toContain("*Available:*"); + expect(r.text).toContain("opus"); + // Caption does NOT list options — just header + hint. + expect(r.menuCaption).toBeDefined(); + expect(r.menuCaption!).not.toContain("*Available:*"); + expect(r.menuCaption!).not.toContain("opus"); + expect(r.menuCaption!).toContain("Current model"); + expect(r.menuCaption!).toContain("/model sonnet"); + }); }); diff --git a/test/telegram-postrich.test.ts b/test/telegram-postrich.test.ts index 6888c2d..489106b 100644 --- a/test/telegram-postrich.test.ts +++ b/test/telegram-postrich.test.ts @@ -198,4 +198,52 @@ describe("TelegramAdapter.postRich", () => { // Did NOT fall back to text (which would call postMessage). expect(postMessageSpy).not.toHaveBeenCalled(); }); + + it("uses menuCaption (not text) as the body when menu renders", async () => { + // RichResponse.text is the verbose fallback ("...Available:..."). + // RichResponse.menuCaption is the concise body shown next to the + // keyboard. Asserting separation: text should NOT appear in the + // sent payload when menuCaption is set. + let capturedText: string | undefined; + const telegramFetch = vi.fn(async (_method: string, payload: Record) => { + capturedText = payload.text as string; + return { ok: true }; + }); + const thread = makeTelegramThread({ chatId: "99", telegramFetch }); + const adapter = new TelegramAdapter(); + + await adapter.postRich(thread, { + text: "Verbose fallback with Available block...", + menuCaption: "Concise caption", + menu: { + sections: [{ + buttons: [{ label: "A", actionId: "x", value: "a" }], + }], + }, + }); + + expect(telegramFetch).toHaveBeenCalledTimes(1); + expect(capturedText).toContain("Concise caption"); + expect(capturedText).not.toContain("Available"); + expect(capturedText).not.toContain("Verbose fallback"); + }); + + it("falls back to RichResponse.text when menuCaption is absent", async () => { + let capturedText: string | undefined; + const telegramFetch = vi.fn(async (_method: string, payload: Record) => { + capturedText = payload.text as string; + return { ok: true }; + }); + const thread = makeTelegramThread({ chatId: "99", telegramFetch }); + const adapter = new TelegramAdapter(); + + await adapter.postRich(thread, { + text: "Just text body", + menu: { + sections: [{ buttons: [{ label: "A", actionId: "x", value: "a" }] }], + }, + }); + + expect(capturedText).toContain("Just text body"); + }); });