You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
TUI: handle sampling and elicitation requests from the server - #2903
The TUI now answers server-initiated sampling/createMessage and elicitation/create requests. Before this change, a tool that asked the user something hung forever. Core already advertised sampling and form elicitation by default, but nothing in the TUI rendered the queue.
PendingRequestModal (new, clients/tui/src/components/) is a full-screen modal that App renders above every other overlay. It reads core's pending-request queue through usePendingClientRequests, the same plumbing the web client's PendingClientRequestModal uses, and shows one request at a time, sampling first.
Sampling shows the messages, system prompt, token budget and model hints above an ink-form response form (text, model pre-filled with stub-model, stop reason). Submitting the form approves the request. Esc then r rejects it.
Form elicitation builds its form from requestedSchema with the tool modal's schemaToForm. Titled enums (oneOf/anyOf of {const, title}) are normalized to selects, and a multi-select is sent as an array. Missing required fields are reported instead of sent. Submitting accepts. Esc then d declines, and Esc then c cancels. On a task-input-required request, cancel cancels the task, as on web (Tasks: io.modelcontextprotocol/tasks extension on modern servers (era fork) #1631).
URL elicitation: o opens the URL in the browser (http/https only, the same allowlist web uses), Enter accepts, d declines and Esc cancels. URL-mode requests can arrive through the -32042 error path whatever the advertised capabilities, so they are handled here.
ink-form owns Esc while a field is being edited. So in the two form modes Esc opens a one-line dismiss prompt instead of settling the request, and any other key returns to the form. The form stays mounted under the prompt, so nothing typed is lost.
App now states sample: true, elicit: { form: true, url: true } instead of relying on core's defaults, which advertise form elicitation only. While a request is open, the global accelerators and every pane stand down (focus becomes "overlay", renamed from "help"). The six other overlays (tool, resource and prompt test modals, details, help, roots) take a new inputActive prop, so Esc meant for the request does not also close the tool modal underneath.
The pure helpers are in clients/tui/src/utils/pendingRequests.ts.
README: a Features entry for the new modal.
Tests
__tests__/pendingRequests.test.ts covers result building, request description, enum normalization, content decoding and missing-field reporting, and the URL allowlist.
__tests__/PendingRequestModal.test.tsx covers every settle path for all three kinds, the once-guard, the back-out-on-any-key prompt, task cancel, and the queue order.
__tests__/App.test.tsx covers the advertised capabilities, the request modal taking Esc over an open tool modal without closing it, and the global accelerators going inert.
npm run local:gate passes.
Screenshots
These were captured from the built TUI driven in a pseudoterminal against a composable stdio test server (collect_sample, submit_ticket). The missing first letter of the tool input in the sampling shots ("ummarize") comes from the capture harness typing faster than ink-text-input re-renders. It is not part of this change.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved input-handling bugs can trigger unintended tool calls or approvals and block recovery after cancellation failures, while elicitation handling can omit answers or submit invalid arrays.
Adds TUI handling for server-initiated sampling and elicitation requests, using core’s pending-request queue so terminal users can respond without switching to the web client.
Copilot review, round 1: 5 findings. 4 fixed in 1a3d251, 1 declined. npm run local:gate passes.
Failed cancellation left the request blocked: fixed. A failed settle now releases the guard.
Esc then Enter submitted the form: fixed. Submission is blocked while the dismiss prompt is open and on the key that closes it.
Inactive modal forms still consumed input: fixed for submission. The tool, prompt, resource and roots forms no longer submit while a request holds input. Keystrokes into the hidden draft are kept on purpose.
A __proto__ field was lost: fixed with Object.fromEntries.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Long elicitation messages can overflow the modal and hide the response fields, Submit button, and dismissal prompt, so the message area needs bounded scrolling that preserves space for the controls.
A long elicitation message can hide the entire response form. For example, a 24-line message on a 24-row terminal consumes more than the modal's available body height: this box cannot shrink, and ModalFrame clips overflow, so the fields, Submit button, and Esc dismissal prompt end up outside the visible area. Please bound the message area and make it scrollable while reserving space for the response controls.
Copilot review, round 2: no inline comments. One "previously missed" finding, which has no thread, so it is answered here.
A long elicitation message could hide the form, Submit and the dismiss prompt: fixed in 0ad3c29. The message now sits in a box capped at a third of the terminal (messageBoxHeight, with unit tests) and scrolls with PgUp/PgDn, so the response controls always keep their space.
The branch is also rebased onto the current v2/main (which includes #2845), and npm run local:gate passes. Requesting round 3.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Long URL-elicitation messages can push the destination outside the modal’s hidden viewport, with no keyboard scrolling to inspect or copy it when browser launch is unavailable.
Advertise sampling and form + URL elicitation explicitly, and render a
pending server request in a full-screen PendingRequestModal driven by
core's pending-request queue (usePendingClientRequests), the same
plumbing the web client uses. Sampling shows the request and a response
form; form elicitation reuses schemaToForm; URL elicitation opens the
link and settles on Enter/d/Esc. Other overlays and panes go inert while
a request is open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
- Release the settle guard when a settle fails (a failed task cancel
leaves the request queued), so the user can retry or answer.
- Block form submission while the Esc dismiss prompt is open and on the
keypress that closes it.
- Hold Enter from submitting a tool/prompt/resource/roots form while a
pending request has their input.
- Build elicitation content with Object.fromEntries so a __proto__ field
survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
)
A long message could push the response form, Submit and the dismiss
prompt out of the modal. Cap the message box at a third of the terminal
(messageBoxHeight) and scroll it with PgUp/PgDn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…2829)
Show the URL first and bound the message under it (messageBoxHeight),
scrolled with PgUp/PgDn, so a long message cannot push the destination
out of the modal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review, round 3: 1 finding, fixed in 7c5d702. A long URL-elicitation message could push the URL out of view. The URL now renders first, and the message is bounded and scrolls with PgUp/PgDn. The branch is rebased onto current v2/main, and npm run local:gate passes. Requesting round 4.
Copilot review closed: round 4 was clean (no inline comments, no headline finding, no suppressed comments), so the loop has ended on its first clean round. Over 4 rounds there were 8 findings: 7 fixed, 1 declined and filed as #2904.
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
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.
Closes #2829
What changed
The TUI now answers server-initiated
sampling/createMessageandelicitation/createrequests. Before this change, a tool that asked the user something hung forever. Core already advertisedsamplingand form elicitation by default, but nothing in the TUI rendered the queue.PendingRequestModal(new,clients/tui/src/components/) is a full-screen modal that App renders above every other overlay. It reads core's pending-request queue throughusePendingClientRequests, the same plumbing the web client'sPendingClientRequestModaluses, and shows one request at a time, sampling first.stub-model, stop reason). Submitting the form approves the request. Esc then r rejects it.requestedSchemawith the tool modal'sschemaToForm. Titled enums (oneOf/anyOfof{const, title}) are normalized to selects, and a multi-select is sent as an array. Missing required fields are reported instead of sent. Submitting accepts. Esc then d declines, and Esc then c cancels. On a task-input-required request, cancel cancels the task, as on web (Tasks: io.modelcontextprotocol/tasks extension on modern servers (era fork) #1631).oopens the URL in the browser (http/https only, the same allowlist web uses), Enter accepts,ddeclines and Esc cancels. URL-mode requests can arrive through the-32042error path whatever the advertised capabilities, so they are handled here.sample: true, elicit: { form: true, url: true }instead of relying on core's defaults, which advertise form elicitation only. While a request is open, the global accelerators and every pane stand down (focus becomes"overlay", renamed from"help"). The six other overlays (tool, resource and prompt test modals, details, help, roots) take a newinputActiveprop, so Esc meant for the request does not also close the tool modal underneath.clients/tui/src/utils/pendingRequests.ts.Tests
__tests__/pendingRequests.test.tscovers result building, request description, enum normalization, content decoding and missing-field reporting, and the URL allowlist.__tests__/PendingRequestModal.test.tsxcovers every settle path for all three kinds, the once-guard, the back-out-on-any-key prompt, task cancel, and the queue order.__tests__/App.test.tsxcovers the advertised capabilities, the request modal taking Esc over an open tool modal without closing it, and the global accelerators going inert.npm run local:gatepasses.Screenshots
These were captured from the built TUI driven in a pseudoterminal against a composable stdio test server (
collect_sample,submit_ticket). The missing first letter of the tool input in the sampling shots ("ummarize") comes from the capture harness typing faster than ink-text-input re-renders. It is not part of this change.Sampling request, with the response filled in:

The tool result after approving:

Form elicitation (

submit_ticket):The Esc dismiss prompt:

The tool result after accepting:

🤖 Generated with Claude Code