Skip to content

Numbered text-fallback + name-or-number choice matcher#114

Merged
bglusman merged 2 commits intomainfrom
ux/text-fallback-numbered-and-matcher
May 3, 2026
Merged

Numbered text-fallback + name-or-number choice matcher#114
bglusman merged 2 commits intomainfrom
ux/text-fallback-numbered-and-matcher

Conversation

@bglusman
Copy link
Copy Markdown
Owner

@bglusman bglusman commented May 2, 2026

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

  • Librarian: `!agent switch librarian`
  • Critic: `!agent switch critic`
    ```

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)

  1. Librarian
  2. Critic
    ```

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:

  1. 1-based number — `"2"`, `"Feat/security profiles #3"`, `" 1 "`
  2. Exact label match — case-insensitive, whitespace-trimmed, punctuation-stripped (`"Sonnet 4.6"` matches `"Sonnet 46"`)
  3. Unique prefix — `"lib"` → `"Librarian"` if no other option starts with `"lib"`
  4. Unique substring — only when the reply is ≥ 2 chars to avoid silly single-letter matches

Returns:

  • `Match::One(&option)` — dispatch the option's `command`
  • `Match::Ambiguous` — multiple labels matched; channel re-prompts
  • `Match::OutOfRange` — number was outside [1, N]; channel re-prompts with valid range
  • `Match::None` — looks like freeform text; fall through to normal command/agent dispatch

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

  • 8 new tests cover the matcher across all tiers + edge cases (numeric out-of-range, label collision, freeform fallthrough, punctuation in labels)
  • Existing `fallback_renders_choice_commands_for_text_only_channels` test was renamed and rewritten to assert the new shape (no internal commands leaked, hint text present, options numbered)
  • New test `fallback_separates_multiple_choice_controls_with_blank_line` covers the multi-control rendering case

`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

`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).
Copilot AI review requested due to automatic review settings May 2, 2026 23:25
@qodo-code-review
Copy link
Copy Markdown

ⓘ 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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 supporting Match enum and normalization helper for number/label/prefix/substring matching.
  • Update and extend unit tests in messages.rs to validate the new fallback shape and matcher behavior.

Comment thread crates/calciforge/src/messages.rs Outdated
Comment on lines 109 to 110
rendered.push_str(&format!("{}. ", idx + 1));
rendered.push_str(option.label.trim());
Comment thread crates/calciforge/src/messages.rs Outdated

// Substring match — only if the reply is at least 2 chars to
// avoid silly matches on single letters.
if normalised_reply.len() >= 2 {
Comment thread crates/calciforge/src/messages.rs Outdated
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 bglusman merged commit 56337ee into main May 3, 2026
18 checks passed
@bglusman bglusman deleted the ux/text-fallback-numbered-and-matcher branch May 3, 2026 01:27
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants