Add interactive Python widgets with host-bridge RPCs and modern HTML UI.Feat/python widgets - #12
Conversation
Cards can call the next tool, shops open maps/phone/web, and widgets follow host theme without React. Co-authored-by: Cursor <cursoragent@cursor.com>
Review: Phase 6 Python widgetsReviewed at 1. A widget render error destroys an otherwise-successful tool call —
|
…discovery, and generate paths cannot break tools or escape the project. Keep tools/call succeeding when HTML fails, always merge widget _meta with caller metadata, and harden URL schemes, preview JSON, and openNow=false. Co-authored-by: Cursor <cursoragent@cursor.com>
Re-review of
|
| # | Finding | Verified result |
|---|---|---|
| 1 | Render error kills tool call | isError: False, content [TextContent, EmbeddedResource, ResourceLink] |
| 2 | Widget _meta dropped |
{'my/telemetry': 1, 'ui': {...}, 'openai/outputTemplate': ...} — caller meta preserved and merged |
| 3 | OAuth discovery shadowed | Stub routes omitted when OAuthModule registered; plain 404 instead of a false "does not use OAuth" |
| 4 | javascript: URLs |
Blocked at all three layers — safe_href, __nitrostack_isSafeUrl, preview page. Case/whitespace variants and data: also rejected |
| 5 | generate tool traversal |
../../ESCAPED → exit 1, nothing written outside the project |
| 6 | OAuth fail-open | Loud stderr warning on startup; .env no longer ships an explicit false |
| 7 | Numeric coercion | "expensive", 10000000 and "abc" all render; priceLevel capped at 4 |
| 8 | Script breakout | <\/script> — now routed through the shared json_for_inline_script helper |
| 9 | preview/call 500s |
400 for malformed JSON, non-object body, and bad arguments; 200 for valid |
| 10 | openNow=false |
5 shops (all), matching omitted — no longer closed-only |
Two things I want to call out as better than a minimal fix: extracting json_for_inline_script so preview_page.py and write_widget_preview share the escaping helper rather than each re-implementing it, and adding DIContainer.has_value() so the OAuth check stops relying on resolve() auto-instantiating. Both remove the class of bug rather than the instance. Regenerating all 13 committed widgets/out/*.html so the static snapshots carry the new scheme guard was the right call too — easy to miss.
Three small new issues introduced by the fix commit
None are blockers; the first is the only one I'd bother fixing before merge.
1. generate tool leaves an orphan file when the two new validators disagree — nitrostack/cli/main.py:1548
_GENERATE_NAME_RE (^[A-Za-z_][A-Za-z0-9_]*$) and _WIDGET_ROUTE_RE (^[A-Za-z0-9][A-Za-z0-9_-]*$) disagree in both directions, and the .py file is written before the route check runs:
$ nitrostack-py generate tool _foo
Error: widget route must be a single alphanumeric path segment
exit=1
$ ls
_foo_tool.py # written anyway, references a @widget("_foo") that has no HTML
Re-running then hits File '_foo_tool.py' already exists, so the user has to delete it by hand. Validating the route up front — before any write — fixes it. (my-tool and 9lives fail the name check but pass the route check, which is the same divergence from the other side.)
2. Skipped widget routes are still reported as created — nitrostack/cli/main.py:1008
unique.append(route) happens before the write attempt, and the new except ValueError: continue is silent:
reported to user as created: ['good-route', 'bad/route']
actually on disk : ['good-route.html']
So init_project prints ✓ Python widgets: good-route, bad/route for a widget that was never scaffolded. Appending only on success, or warning on skip, makes the output honest.
3. _safe_int misses OverflowError — nitrostack/widgets/views.py:35
The new helper catches (TypeError, ValueError), but int(float('inf')) raises OverflowError:
_safe_int(float('inf')) -> OverflowError
_safe_float(float('inf')) -> 0.0 # sibling handles it correctlyReachable via 1e400 in upstream JSON, which json.loads yields as inf. Impact is small precisely because of the defense-in-depth added in this same commit — _widget_result_content catches it, so the tool call survives — but the EmbeddedResource is dropped and the widget silently fails to render. Adding OverflowError to the except clause, or mirroring the inf guard _safe_float already has at line 47, closes it.
Nice turnaround on this. Findings 1–4 from the original review are the ones that mattered and all four are properly fixed at the root rather than patched at the call site. From my side this is good to merge once the orphan-file case is handled; the other two are cosmetic.
generate tool: validate the widget route before writing anything. The name and
route validators accept different character sets in both directions (`_foo` is
a valid identifier but an invalid route; `my-tool` is the reverse), and the
route was only checked inside write_widget_html — after the .py file had
already been written. That left an orphan file which then blocked the retry
with "File already exists".
ensure_python_widgets: append a route to the returned list only after its HTML
is actually written, and warn instead of skipping silently. Callers print that
list as "created", so a rejected route was being reported as a widget that had
been scaffolded when nothing landed on disk. Also dedupes via a set so a route
repeated across files is attempted once.
_safe_int: catch OverflowError. json.loads("1e400") yields inf and
int(float("inf")) raises, which _safe_float already guards against but
_safe_int did not — the tool call survived thanks to the defense-in-depth in
the same commit, but the EmbeddedResource was dropped and the widget silently
failed to render.
Each fix has a regression test in tests/test_pr12_review.py, all three verified
to fail without the corresponding change. 206 tests pass, up from 203.
Summary
callTool,open-link, fullscreen, theme, and widget state.Test plan
pytest testsinnitrostack-python-sdk//widgets/preview: pizza list card → shop; Maps/Call/Website; Expand