@thoth-agents/pi-questions-user
Structured interactive user questions for Pi with a sequential RPC fallback.
Package details
Install @thoth-agents/pi-questions-user from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@thoth-agents/pi-questions-user- Package
@thoth-agents/pi-questions-user- Version
0.5.2- Published
- Oct 21, 2026
- Downloads
- 962/mo · 392/wk
- Author
- eremesng
- License
- MIT
- Types
- extension
- Size
- 114.3 KB
- Dependencies
- 0 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"./dist/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@thoth-agents/pi-questions-user
First-party ask_user_question for Pi. MIT; Node >=22.19.0, Pi peers >=0.99.0;
development SDK/TUI 1.0.2. The tool is registered with exposure: 'model-only'.
Install
pi install npm:@thoth-agents/pi-questions-user
Shared question service
In UI sessions, this extension also provides its framed questionnaire, previews,
and editor dock to other Thoth packages through pi-core's versioned question
service. It registers on session_start and withdraws its own provider on
shutdown; headless sessions do not register one.
Service callers can set per-question allowCustom: false to hide free-text
answers. Requests return structured answered, cancelled, or unavailable
results. Aborting disposes the dock and resolves as cancelled; if the dock cannot
mount (including RPC contexts), the service returns unavailable so the caller
can choose its fallback. The service itself does not open sequential dialogs.
This does not change the ask_user_question tool's schema or behavior described
below: its single/multi questions still offer free text, and its sequential
select/input fallback remains available.
Parameters
{
"title": "Implementation plan",
"questions": [
{
"id": "approach",
"header": "Approach",
"context": "**Summary:** Small, reviewable changes with tests first.",
"prompt": "Which approach should we use?",
"type": "single",
"required": true,
"options": [
{
"value": "safe",
"label": "Safe approach",
"description": "Small, reviewable changes",
"preview": "## Plan\nStart with tests.",
"recommended": true
}
]
}
]
}
title, labels, per-question context, type, required, option description,
preview, and recommended are optional.
Type defaults to single; types are single, multi, text, confirm. There
are no declared maximum counts or string lengths. At least one question is
required. IDs and option values must be unique (values within each question),
non-blank strings; headers, prompts and labels must be non-blank. Single/multi
need options. Text/confirm accept no options (an empty array is also allowed).
Confirm synthesizes {value: "yes", label: "Yes"} and {value: "no", label: "No"}
(values are stable; only the labels localize). Type something. and the
localized typeSomething label are reserved option labels.
Self-contained questions
Always write the proposal, table or findings as normal visible response text
before calling the tool, not inside reasoning/thinking. Keep prompt a concise,
self-contained plain-text question. Reasoning/thinking is not shown as answer text.
Assistant prose in the same turn may not be visible while the question is open,
so context is a secondary fallback: include only a short markdown summary
needed to answer, never the main place for long proposals or tables. Use option
preview for per-option markdown details.
context must be a string; blank context is treated as absent.
The native questionnaire renders context above the prompt with the same markdown renderer as previews, including tables. Context and prompt share the existing bounded reading viewport and Alt+arrow scroll controls, with hidden-row indicators; options/editors retain their reserved space. The sequential RPC fallback includes context before the prompt as source text in dialog titles. Review stays compact and shows answer summaries rather than repeating context.
Localizable UI labels
Every fixed UI string is English by default. The optional top-level labels
object overrides any of them; each value must be a non-blank string, omitted keys
keep the English default, and unknown keys are ignored. The tool's prompt guideline
asks models to send labels (and headers/prompts/option labels) in the user's
language. Keys: askUser, yes, no, typeSomething, yourAnswer, submit,
backToEdit, cancel, review, reviewHeading, required, unanswered,
requiredPending, skipped, skip, done, recommended, preview,
noDescription, typeOwn, noTextYet, note, noteForQuestion, noteFor,
cancelled, cancelledKept, error, answered, and the key-hint verbs move,
pick, select, toggle, next, optionNote, questionNote, clear,
scrollPreview, scrollPrompt, scrollContext (default context/prompt), more,
switchTab, save, newline, keepDraft, collapse, expand. The native
questionnaire, the sequential RPC fallback, synthesized confirm options and the
transcript result card all use them. When supplied, labels is echoed in
details.labels so the result card renders in the same language; it is absent
otherwise.
Example labels fragment (include it alongside a non-empty questions array):
{ "labels": { "yes": "Sí", "no": "No", "submit": "Enviar respuestas" } }
required is advisory: a user may skip any question. Recommendations are markers,
never selected or submitted automatically. In the questionnaire, a colored ★
gutter between the radio/checkbox and label marks recommended options; other rows
reserve the same space only when the question has a recommendation. Labels are
not suffixed with recommendation text. The focused recommendation's preview header
shows Preview · ★ Recommended (localized), retaining its scroll range.
Single/multi offer a free-text entry;
multi combines it with picks. Text is input only. The sequential fallback uses
numbered options to distinguish duplicate labels and control-like labels, repeated
multi toggles plus Done, and explicit Skip (blank text also skips). It uses
select/input dialogs with option descriptions in the choice labels; it does not
collect question or option notes or render previews.
Results
content is a readable per-question summary. details contains optional title,
cancelled, optional error, answers keyed by question ID, and questions
metadata (including synthesized confirm options). Each answer has status: "answered" | "skipped", values, labels, and optional customText, note,
optionNotes keyed by option value. Text-only answers have empty values/labels.
Cancelled requests retain recorded answers; the summary explicitly says unanswered
questions must not be assumed. Error codes are invalid_questions, no_ui,
aborted. In UI-enabled calls, invalid input returns invalid_questions with
issues: [{path, code, message}]; no interaction is attempted. Headless calls
return no_ui even with invalid input. invalid_questions is not user
cancellation; no_ui and aborted are.
Pure state API (src/answers.ts)
Validate before creating state. Every transition returns a new state; keep that returned value. No transition mutates its input. Initial answers are skipped, without selections. Result and initial metadata snapshots are detached copies.
createState(questionnaire: Questionnaire): AnswerState
selectOption(state: AnswerState, id: string, value: string): AnswerState
toggleOption(state: AnswerState, id: string, value: string): AnswerState
setCustomText(state: AnswerState, id: string, text: string): AnswerState
setQuestionNote(state: AnswerState, id: string, note: string): AnswerState
setOptionNote(state: AnswerState, id: string, value: string, note: string): AnswerState
markSkipped(state: AnswerState, id: string): AnswerState
buildResult(state: AnswerState, options?: ResultOptions): QuestionResult
selectOptionis for single/confirm; it replaces prior picks and custom text.toggleOptionis for multi; it preserves custom text and orders picks by metadata.setCustomTextreplaces single picks, combines with multi picks, and is rejected for confirm. Blank text removes custom text.- Notes do not make a question answered; blank notes remove annotations. Option notes may be attached to unselected options.
markSkippedclears picks/custom text but retains notes. Unknown question IDs, unknown option values and incompatible actions throw rather than invent answers.
Exported types:
schema.ts:QuestionParametersis the raw tool input;QuestionTypeis the four-type union;QuestionOptionis option metadata;Questionhas a resolved type;Questionnairehas optional title and normalized questions.validate.ts:ValidationIssueCodeenumeratesinvalid_type,blank,empty_questions,duplicate_id,missing_options,unexpected_options,duplicate_value,reserved_label.ValidationIssueincludes path/code/message.ValidationResultdiscriminates{valid: true, value}/{valid: false, issues}.validateQuestions(input: unknown): ValidationResulthandles shape and semantics.answers.ts:QuestionAnsweris the answer shape above;AnswerStatecombines questionnaire metadata and answers.QuestionErroris the error-code union.ResultOptionshas optional cancelled/error/issues.QuestionDetailsadds the result flags to state.QuestionResulthas text content and details.
Custom-UI seam and lifecycle
createQuestionTool(uiHook?: QuestionUIHook) in src/index.ts defaults to the
native TUI questionnaire via src/custom-ui.ts#getQuestionUIFactory. It provides
question tabs, windowed option lists, free-text and note editors, previews beside
the options on wide terminals (below on narrow ones), and a review tab for multiple
questions. A single question may submit directly.
The questionnaire is drawn in the thoth frame: a rounded border with the title,
a tabs row, dividers around the question/option/preview area and a two-row hint
footer. The frame comes from the pi-core render kit resolved at render time and
falls back to the same glyphs drawn natively when no kit is registered (the theme
package is never a dependency). Expanded height stays stable across options, tabs
and editors for a given questionnaire and terminal size. Its baseline is
max(12, floor(rows * 0.4)); content can grow it up to floor(rows * 0.65) (never
below that baseline). Need is measured across all questions: frame chrome, full
rendered context and wrapped prompts, wrapped option labels and previews. The
reading area takes priority, then labels with hanging indents; spare rows go to the preview. At the cap, the narrow
preview shrinks to a minimum of up to two body rows of actual content, then lists
scroll by whole options, and finally overflowing prompts scroll with a visible indicator. In tiny terminals
that cannot fit those minimums, preview rows shrink further to preserve the height
cap and selected option. Wide terminals (100+ columns) wrap labels within the
list column beside the scrolling preview. Active editors reserve their native
borders and at least the cursor line before the prompt; tall drafts clip around
the cursor. In tiny terminals the editor label yields to that minimum.
The expanded questionnaire replaces the editor through pi-core's shared editor
slot; it is not an overlay and does not cover the chat. Native scrollback works in
inline mode; fullscreen PageUp/PageDown continue scrolling the transcript.
Ctrl+] collapses it to a one-line dock above the restored editor and returns
keyboard input to the editor. Typing and Enter work normally there (Enter
during an active run queues a steering message; it does not answer the question).
Ctrl+] from the editor or a history/detail overlay expands it again, restoring
its answers, tab, cursor, scroll positions and editor drafts. The open tool-call
card marks the question as collapsed until it is expanded or completed.
Closing subagents, task-list or background history, or the work-panel detail card, returns input to the expanded question or otherwise the editor. Collapsing, answering, cancelling or aborting never takes focus from a visible foreign overlay.
The registered tool includes call/result renderers using the pi-core render kit
when available, with native Pi rendering as fallback. The call and result parts
share one final border tone (success/error/neutral) so the whole card reflects the
outcome; the result card lists each question's header in an aligned column with its
answer (✓ options, quoted custom text, ○ skipped) and indented ✎ notes. UI sessions publish the full
tool definition to the pi-core tool registry on session_start; shutdown withdraws
only that session's publication. Headless sessions do not publish it.
QuestionUIHook(session: QuestionUISession): QuestionUIFactory | undefined receives
initial state, optional signal, onStateChange(state) and the optional
onCollapseChange(collapsed) focus/presentation callback. The component should
report recorded state after each transition to preserve answers if aborted, and
listen to the signal to complete native teardown. QuestionUIFactory uses Pi's
native (tui, theme, keybindings, done) signature and returns a component (or promise).
Call done(buildResult(state)) to submit,
done(buildResult(state, {cancelled: true})) to cancel, or
done(buildResult(state, {cancelled: true, error: "aborted"})) on abort. The default
component calls done at most once to restore Pi's editor, keeps recorded answers,
and removes its abort listener on completion/disposal.
Undefined means custom UI unavailable, not user cancellation. A UI hook returning no factory, unavailable editor-slot hooks (as in RPC mode), or its undefined completion sentinel runs the sequential select/input fallback. Unexpected host errors propagate rather than masquerading as user cancellation. Dialogs receive the abort signal and are raced against it so the tool settles even when a host ignores cancellation.
The extension removes only ask_user_question from active tools in
before_agent_start when ctx.hasUI is false, without changing other tool choices.
Forced calls still return no_ui. UI-enabled sessions do not override an
operator's explicit tool loadout.
Keyboard controls
Questionnaire navigation/action bindings are fixed and not user-rebindable:
Tab/Shift+Tab,Right/Left: switch question/review tabs, wrapping.Up/Down,Home/End: move within an option list;Up/Downalso navigate review rows.1–9pick an option (advance for single/confirm, toggle for multi).Enter: select and advance for single/confirm, open free text, advance/submit for multi, or activate a review row/action.Spaceselects without advancing (toggles for multi) and also activates review rows/actions.n: edit the focused option's note;N: edit the question note.x/Delete: clear picks/custom text, retaining notes.Shift+Up/Shift+Down: scroll the focused option's preview by a line;[/](andPageUp/PageDown) scroll by half a page. A header shows the visible range and↑ N more/↓ N more. Fullscreen Pi consumesPageUp/PageDown,Home/End,Ctrl+Up/Downand the mouse wheel for the transcript before the questionnaire sees them, soShift+arrowsand[/]are the keys that always work.Alt+Up/Alt+Down: scroll overflowing context/prompt by a line; its indicator shows hidden rows. The hint appears only when the reading viewport overflows; switching tabs resets prompt scroll.Ctrl+]: collapse/expand while a question is open, from the editor, questionnaire or a history/detail overlay. This terminal-input binding shadows the editor'sjumpForwardbinding only while the question is open. Kitty repeats/releases do not toggle again.- While collapsed, all other input (including
Esc) belongs to the editor or focused overlay. To cancel the question, expand it withCtrl+], then useEsc. Escoutside an editor: cancel, retaining recorded answers.
Text/note editors use Pi's native editor for typing and editing (Enter saves,
Shift+Enter inserts a newline with default editor bindings). Esc keeps the draft
and returns to the question; Tab / Shift+Tab keep the draft and switch tabs.
Verification
pnpm --filter @thoth-agents/pi-questions-user run typecheck
pnpm --filter @thoth-agents/pi-questions-user run test
pnpm exec biome ci pi-packages/pi-questions-user
Tests use Vitest's terminating offline Node runner, fake native UI boundaries, and real SDK/TUI 1.0.2 focus and open-tool-row invalidation regressions.