Numbered text-fallback + name-or-number choice matcher#114
Merged
Conversation
`OutboundMessage::render_text_fallback` now produces a clean numbered
list with an explicit "(reply with name or number)" hint for each
ChoiceControl, instead of the prior shell-style "- Label: \`!command\`"
rendering that exposed internal command syntax. The user's reply is
the entry point — the channel side resolves it to a command via the
new matcher rather than asking the user to type the command verbatim.
Adds `ChoiceControl::match_reply(&str) -> Match`. Match tiers:
1. 1-based number ("2", "#3")
2. Exact label match (case/whitespace/punctuation-insensitive)
3. Unique label prefix
4. Unique label substring (≥ 2 chars)
Returns `Match::Ambiguous` when multiple labels collide so the channel
can re-prompt instead of dispatching the wrong action. Returns
`Match::None` for freeform text that doesn't look like a selection,
so channels can fall through to normal command/agent dispatch.
`Match::OutOfRange` when the user typed a number outside [1, N].
Universal value: every text-fallback channel (Signal, WhatsApp,
Matrix, SMS, mock) gets a better UX immediately. Native-rendering
channels (Telegram inline keyboards) are unaffected.
The matcher is currently allow(dead_code) — wiring it into each
channel's inbound dispatch (with per-identity pending-choice state +
TTL) is a follow-up PR. The matcher itself ships first because the
design is non-trivial and worth landing as a stable utility.
Test count: +8 (existing render test updated; new tests cover hint
text, multiple controls, numeric/label/prefix/substring/ambiguous/
out-of-range/freeform-fallthrough cases).
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
There was a problem hiding this comment.
Pull request overview
This PR improves Calciforge’s channel-agnostic OutboundMessage UX for text-only channels by rendering ChoiceControl options as numbered lists with a “reply with name or number” hint, and adds a utility matcher to resolve free-text replies to a specific option.
Changes:
- Update
render_text_fallback()to hide internal command strings and render numbered option labels with a selection hint. - Add
ChoiceControl::match_reply()plus supportingMatchenum and normalization helper for number/label/prefix/substring matching. - Update and extend unit tests in
messages.rsto validate the new fallback shape and matcher behavior.
Comment on lines
109
to
110
| rendered.push_str(&format!("{}. ", idx + 1)); | ||
| rendered.push_str(option.label.trim()); |
|
|
||
| // Substring match — only if the reply is at least 2 chars to | ||
| // avoid silly matches on single letters. | ||
| if normalised_reply.len() >= 2 { |
Comment on lines
+394
to
+403
| // Two separate "(reply with name or number)" hints, one per control, | ||
| // separated by blank lines so the user can disambiguate. | ||
| let hints: Vec<_> = rendered | ||
| .match_indices("(reply with name or number)") | ||
| .collect(); | ||
| assert_eq!( | ||
| hints.len(), | ||
| 2, | ||
| "each control must have its own selection hint: {rendered}" | ||
| ); |
Address reviewer regression concern (PR #114): rendering without the matcher wired makes ChoiceControls non-actionable on text-only channels (Signal/WhatsApp/Matrix/SMS/mock). Reverting the render change here so PR #114 becomes purely additive — adds the matcher utility but does not change user-visible output. A follow-up PR will land the proper UX (numbered render + matcher wiring + per-channel pending-choice state) as one cohesive change. Also fix the byte-vs-char length check in match_reply per reviewer feedback: `len()` is byte count, which would let a single multi-byte Unicode glyph through the substring guard. Use `chars().count()` for the semantic 'at least 2 visible characters' check.
bglusman
added a commit
that referenced
this pull request
May 3, 2026
…116) Bumps Calciforge to a fork branch of zeroclawlabs that adds two features upstream silently dropped: * Signal: dataMessage.pollAnswer field exposed via ChannelMessage content '[choice]<title>' (or '[choice-index]N' fallback) so poll-vote events flow back to the dispatcher. * WhatsApp Cloud: interactive.button_reply / list_reply parsing in webhook payload, surfacing the option's id as '[choice]<id>'. Plus outbound send_interactive_buttons / send_interactive_list helpers (Meta /v18.0/{phone_number_id}/messages with the interactive body shape). The 0.6.9 → 0.7.x major-version bump is a structural refactor: upstream split the umbrella crate into per-concern subcrates (zeroclaw-api, zeroclaw-channels, zeroclaw-config, etc.). Calciforge adapts via mechanical import-path renames: zeroclaw::channels::traits::* → zeroclaw_api::channel::* zeroclaw::channels::SignalChannel → zeroclaw_channels::signal::SignalChannel zeroclaw::channels::WhatsAppWebChannel → zeroclaw_channels::whatsapp_web::WhatsAppWebChannel zeroclaw::channels::LinqChannel → zeroclaw_channels::linq::LinqChannel zeroclaw::channels::linq::verify_linq_signature → zeroclaw_channels::linq::verify_linq_signature zeroclaw::config::WhatsAppWebMode → zeroclaw_config::schema::WhatsAppWebMode zeroclaw::config::WhatsAppChatPolicy → zeroclaw_config::schema::WhatsAppChatPolicy No semantic changes; everything Calciforge consumed at the call-site level still has the same shape on master. The umbrella zeroclawlabs package stays as a dep (its own ZeroClawAdapter + lib name 'zeroclaw' still exist). Adds three new workspace deps for the subcrates that host channels, traits, and config types. Cargo.toml uses git+branch references rather than tag/version because the fork's calciforge-interactive branch tracks upstream master with our two patches on top. Once upstream merges the patches, this can flip back to a published version. The branch is small and easy to rebase on upstream churn. The matcher already added in PR #114 will pair with this in a follow-up PR that wires native poll/interactive rendering on signal::send_outbound and whatsapp::send_outbound when OutboundMessage.controls is non-empty. Co-authored-by: Librarian <librarian@glusman.me>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Calciforge has a unifying `OutboundMessage` + `ChoiceControl` + `ChoiceOption` abstraction. Every channel implements `send_outbound` and decides how to render. Telegram renders `ChoiceControl` natively as inline keyboards (works great per testing). Signal, WhatsApp, Matrix, SMS, and mock all fall back to text via `render_text_fallback`.
The old fallback exposed internal command syntax:
```
Choose
```
Users had to type the literal command. Ugly, unfamiliar to anyone who hasn't read the docs, and not symmetric with how Telegram works (tap a button → bot responds).
What changes
Rendering — every text-fallback channel now sees:
```
Choose
(reply with name or number)
```
Internal command syntax is hidden; the user picks by name or number; the channel-side matcher resolves to the command.
Matcher — `ChoiceControl::match_reply(&str) -> Match` resolves a free-text reply against the control's options:
Returns:
Scope
This PR ships the rendering change AND the matcher utility. It does not wire the matcher into per-channel inbound dispatch — that needs per-identity pending-choice state with TTL, which is a follow-up PR.
The matcher is `#[allow(dead_code)]` for now. The function-level tests cover all match tiers. Wiring is bounded and will land in a separate "channels: pending-choice matcher integration" PR alongside or shortly after this one.
Tests
`cargo test -p calciforge messages::` → 11 passed (was 3). Build / clippy / fmt clean.
Independent of
Stats
`+238 / -11` LOC, single file (`crates/calciforge/src/messages.rs`).
🤖 Generated with Claude Code