Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/rich-ui-surface-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 12 additions & 2 deletions src/transports/rich-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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 }],
},
Expand Down
5 changes: 4 additions & 1 deletion src/transports/telegram/telegram-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions src/transports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
25 changes: 25 additions & 0 deletions test/rich-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
48 changes: 48 additions & 0 deletions test/telegram-postrich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
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<string, unknown>) => {
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");
});
});
Loading