feat(ask): add initial ask tool prototype and project setup

This commit is contained in:
Erik Kotsiuba
2026-04-16 17:56:01 +02:00
commit 133a0548ba
16 changed files with 4039 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.DS_Store
.zed/private.json
+50
View File
@@ -0,0 +1,50 @@
{
"format_on_save": "on",
"ensure_final_newline_on_save": true,
"remove_trailing_whitespace_on_save": true,
"languages": {
"TypeScript": {
"formatter": {
"external": {
"command": "pnpm",
"arguments": [
"exec",
"biome",
"format",
"--stdin-file-path",
"{buffer_path}"
]
}
},
"hard_tabs": true
},
"JSON": {
"formatter": {
"external": {
"command": "pnpm",
"arguments": [
"exec",
"biome",
"format",
"--stdin-file-path",
"{buffer_path}"
]
}
}
},
"JSONC": {
"formatter": {
"external": {
"command": "pnpm",
"arguments": [
"exec",
"biome",
"format",
"--stdin-file-path",
"{buffer_path}"
]
}
}
}
}
}
+186
View File
@@ -0,0 +1,186 @@
# pi-ask
This project explores a **pi extension** that brings an `ask` capability into the **pi.dev harness**.
## What this is
The goal is to add a tool or extension layer that lets pi users ask questions through a dedicated `ask` interface, while fitting naturally into the pi.dev agent workflow.
## Why
Different agent harnesses expose question/answer style tools in different ways. This repository will collect research on those implementations and use that knowledge to design a pi extension for pi.dev.
## Contents
- `RESEARCH.md` — notes on how `ask` is implemented in different harnesses
- `FINDINGS.md` — synthesized findings from source research
- `SPEC.md` — implementation spec for a pi-native ask/interview tool
- `src/` — TypeScript extension implementation
- `tests/` — TypeScript tests for core logic/result formatting
## Usage
### Load the extension in pi
Run pi with the extension entrypoint:
```bash
pi -e ./src/index.ts
```
This project is designed for dynamic loading via the `-e` flag.
### Install dependencies
```bash
pnpm install
```
### Run tests
```bash
pnpm test
```
### Biome / typecheck
```bash
pnpm format
pnpm lint
pnpm check
pnpm typecheck
```
### Zed
This repo includes project-local Zed settings at:
- `.zed/settings.json`
They are set up to:
- format TypeScript, JSON, and JSONC files with `pnpm exec biome format --stdin-file-path {buffer_path}`
- format on save
- keep newline / trailing whitespace behavior aligned with the repo settings
Note: some Zed settings such as global extension auto-install and autosave are not valid in project-local `.zed/settings.json`, so they are intentionally not included here.
## Tool
The extension registers a tool named `ask_user`.
Use it when the agent needs structured clarification before proceeding.
### Input shape
`ask_user` accepts:
```ts
{
title?: string,
questions: [
{
id: string,
label?: string,
prompt: string,
type?: "single" | "multi" | "code-review",
allowOther?: boolean,
required?: boolean,
options: [
{
value: string,
label: string,
description?: string,
preview?: string
}
]
}
]
}
```
### Example tool call payload
```json
{
"title": "Implementation preferences",
"questions": [
{
"id": "style",
"label": "Style",
"prompt": "How should I frame the next prompt?",
"type": "single",
"options": [
{
"value": "minimal",
"label": "Minimal",
"description": "A short, direct question with few options."
},
{
"value": "balanced",
"label": "Balanced",
"description": "A standard prompt with a bit more context."
},
{
"value": "rich",
"label": "Rich",
"description": "A more descriptive prompt with extra detail."
}
]
},
{
"id": "frameworks",
"label": "Frontend",
"prompt": "Which frontend frameworks have you used?",
"type": "multi",
"options": [
{ "value": "react", "label": "React", "description": "Most popular UI library" },
{ "value": "vue", "label": "Vue", "description": "Progressive framework for building UIs" },
{ "value": "svelte", "label": "Svelte", "description": "Compiler-based approach" }
]
}
]
}
```
## Current UX
The current prototype supports:
- tabbed multi-question flow
- single-select questions
- multi-select questions
- fixed `Type something.` fallback option
- number-key quick selection
- final submit/review page
- transcript rendering for call/result rows
## Example agent instruction
You can give the agent an instruction like this to encourage proper `ask_user` usage:
```text
When requirements are ambiguous or user preferences materially affect implementation,
call `ask_user` instead of guessing.
Ask 1-3 concise questions.
Use short tab labels.
Prefer 2-4 options per question.
Include descriptions for each option.
Allow custom input when the fixed choices may be insufficient.
Use `type: "single"` unless multiple options can genuinely apply.
Use `type: "multi"` only when the user may need to select several answers.
After answers are returned, continue the task using those answers explicitly.
```
### Example request to the agent
```text
Before implementing, clarify my preferences with `ask_user` if needed.
For example, ask about framework choice, styling approach, and testing strictness.
Do not guess if those choices would change the implementation.
```
## Status
Prototype implemented in TypeScript and test-covered for core logic.
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"dependencies": {
"@mariozechner/pi-coding-agent": "0.67.3",
"@mariozechner/pi-tui": "0.67.3",
"@sinclair/typebox": "0.34.49"
},
"devDependencies": {
"@biomejs/biome": "2.4.12",
"@types/node": "25.6.0",
"typescript": "6.0.2"
},
"name": "pi-ask",
"scripts": {
"test": "node --test tests/*.test.ts",
"typecheck": "tsc -p tsconfig.json",
"format": "biome format --write .",
"lint": "biome lint --write .",
"check": "biome check --write .",
"check:ci": "biome ci ."
},
"type": "module",
"private": true,
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"@mariozechner/pi-agent-core": "0.67.3",
"@mariozechner/pi-ai": "0.67.3",
"@mariozechner/pi-tui": "0.67.3",
"@mariozechner/pi-coding-agent": "0.67.3"
}
}
}
+2604
View File
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
truncateToWidth,
} from "@mariozechner/pi-tui";
import {
allRequiredAnswered,
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
getAnswer,
getCurrentQuestion,
getRenderableOptions,
isQuestionAnswered,
isSubmitTab,
moveOption,
moveTab,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "./state.ts";
import type { AskParams, AskResult, AskState } from "./types.ts";
export async function runAskFlow(
ctx: ExtensionContext,
params: AskParams,
): Promise<AskResult> {
return ctx.ui.custom<AskResult>((tui, theme, _keybindings, done) => {
let state: AskState = createInitialState(params);
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (value) => {
state = submitCustomAnswer(state, value);
editor.setText("");
refresh();
maybeFinish();
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function maybeFinish() {
if (state.completed) {
done(toAskResult(state));
}
}
function handleInput(data: string) {
if (state.mode === "input") {
if (matchesKey(data, Key.escape)) {
state = cancelFlow(state);
if (!state.completed) {
refresh();
return;
}
maybeFinish();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
state = moveTab(state, 1);
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
state = moveTab(state, -1);
refresh();
return;
}
if (matchesKey(data, Key.up)) {
state = moveOption(state, -1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
state = moveOption(state, 1);
refresh();
return;
}
if (matchesKey(data, Key.space)) {
const question = getCurrentQuestion(state);
if (question?.type === "multi") {
state = toggleCurrentMultiOption(state);
refresh();
return;
}
}
if (matchesKey(data, Key.enter)) {
state = confirmCurrentSelection(state);
refresh();
maybeFinish();
return;
}
if (matchesKey(data, Key.escape)) {
state = cancelFlow(state);
refresh();
maybeFinish();
return;
}
const digit = parseDigit(data);
if (digit !== null) {
state = applyNumberShortcut(state, digit);
refresh();
return;
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (text = "") => lines.push(truncateToWidth(text, width));
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
if (state.title) {
add(` ${theme.fg("accent", theme.bold(state.title))}`);
add();
}
add(renderTabs(state, theme, width));
add();
if (isSubmitTab(state)) {
renderSubmit(lines, state, theme, width);
} else if (question) {
add(` ${theme.fg("text", question.prompt)}`);
add();
const isMulti = question.type === "multi";
for (let i = 0; i < options.length; i++) {
const option = options[i];
const selected = i === state.optionIndex;
const checked = !!getAnswer(state, question.id)?.values.includes(
option.value,
);
const pointer = selected ? theme.fg("accent", "❯ ") : " ";
const prefix =
isMulti && !option.isOther ? `[${checked ? "x" : " "}] ` : "";
const optionColor = selected ? "accent" : "text";
add(
`${pointer}${theme.fg(optionColor, `${i + 1}. ${prefix}${option.label}`)}`,
);
if (option.description) {
add(` ${theme.fg("muted", option.description)}`);
}
if (question.type === "code-review" && selected && option.preview) {
add();
for (const previewLine of option.preview.split("\n")) {
add(` ${theme.fg("dim", previewLine)}`);
}
add();
}
}
if (state.mode === "input") {
add();
add(` ${theme.fg("muted", "Your answer:")}`);
for (const editorLine of editor.render(Math.max(1, width - 2))) {
add(` ${editorLine}`);
}
add();
}
}
add();
add(renderFooter(state, theme));
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
cachedLines = lines;
return lines;
}
return {
render,
invalidate() {
cachedLines = undefined;
},
handleInput,
};
});
}
function renderTabs(
state: AskState,
theme: ExtensionContext["ui"]["theme"],
width: number,
): string {
const segments: string[] = [theme.fg("dim", "← ")];
for (let index = 0; index < state.questions.length; index++) {
const question = state.questions[index];
const active = state.currentTab === index;
const answered = isQuestionAnswered(state, question.id);
const marker = answered ? "☒" : "☐";
const text = ` ${marker} ${question.label} `;
segments.push(
active
? theme.bg("selectedBg", theme.fg("text", text))
: theme.fg(answered ? "success" : "muted", text),
);
}
const submitActive = isSubmitTab(state);
const submitText = " ✔ Submit ";
segments.push(
submitActive
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(allRequiredAnswered(state) ? "success" : "dim", submitText),
);
segments.push(theme.fg("dim", " →"));
return truncateToWidth(` ${segments.join(" ")}`, width);
}
function renderSubmit(
lines: string[],
state: AskState,
theme: ExtensionContext["ui"]["theme"],
width: number,
) {
const add = (text = "") => lines.push(truncateToWidth(text, width));
add(` ${theme.fg("accent", theme.bold("Review your answers"))}`);
add();
for (const question of state.questions) {
const answer = getAnswer(state, question.id);
add(` ${theme.fg("muted", `● ${question.prompt}`)}`);
add(
` ${theme.fg("text", `→ ${answer ? answer.labels.join(", ") : "(unanswered)"}`)}`,
);
}
add();
add(
` ${allRequiredAnswered(state) ? theme.fg("success", "Ready to submit your answers?") : theme.fg("warning", "Answer all required questions before submitting.")}`,
);
add();
const options = ["Submit answers", "Cancel"];
for (let i = 0; i < options.length; i++) {
const selected = i === state.submitIndex;
const prefix = selected ? theme.fg("accent", "❯ ") : " ";
add(
`${prefix}${theme.fg(selected ? "accent" : "text", `${i + 1}. ${options[i]}`)}`,
);
}
}
function renderFooter(
state: AskState,
theme: ExtensionContext["ui"]["theme"],
): string {
if (state.mode === "input") {
return theme.fg("dim", " Enter submit · Esc go back");
}
const question = getCurrentQuestion(state);
if (isSubmitTab(state)) {
return theme.fg("dim", " ↑↓ select · Enter confirm · Esc cancel");
}
if (question?.type === "multi") {
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · space toggle · enter continue · esc dismiss · 1-9 quick toggle",
);
}
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · enter confirm · esc dismiss · 1-9 quick select",
);
}
function parseDigit(data: string): number | null {
return /^[1-9]$/.test(data) ? Number(data) : null;
}
+95
View File
@@ -0,0 +1,95 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { runAskFlow } from "./ask-component.ts";
import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.ts";
import { createInitialState, summarizeResult, toAskResult } from "./state.ts";
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user",
label: "Ask User",
description:
"Ask the user one or more structured questions. Supports single-select, multi-select, and typed custom answers. Use this when clarification is required before proceeding.",
parameters: AskParamsSchema,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
const details = errorResultDetails(params, true);
return {
content: [
{
type: "text",
text: "Error: UI not available (running in non-interactive mode)",
},
],
details,
};
}
if (!params.questions?.length) {
const details = errorResultDetails(params, true);
return {
content: [{ type: "text", text: "Error: No questions provided" }],
details,
};
}
const result = await runAskFlow(ctx, params);
return {
content: [{ type: "text", text: summarizeResult(result) }],
details: result,
};
},
renderCall(args, theme) {
const params = args as AskParams;
const labels = Array.isArray(params.questions)
? params.questions
.map(
(question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`,
)
.join(", ")
: "";
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 50)})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as AskResult | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
return new Text(renderResultText(details), 0, 0);
},
});
}
function errorResultDetails(params: AskParams, cancelled: boolean): AskResult {
try {
const state = createInitialState(params);
return {
...toAskResult(state),
cancelled,
};
} catch {
return {
title: params.title,
cancelled,
questions: [],
answers: {},
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { registerAskTool } from "./ask-tool.ts";
export default function askExtension(pi: ExtensionAPI) {
registerAskTool(pi);
}
+18
View File
@@ -0,0 +1,18 @@
import type { AskResult } from "./types.ts";
export function renderResultText(result: AskResult): string {
if (result.cancelled) {
return "Cancelled";
}
const lines: string[] = [];
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) continue;
const value = answer.customText
? `(wrote) ${answer.customText}`
: answer.labels.join(", ");
lines.push(`✓ ${question.label}: ${value}`);
}
return lines.join("\n") || "Submitted";
}
+54
View File
@@ -0,0 +1,54 @@
import { Type } from "@sinclair/typebox";
export const AskOptionSchema = Type.Object({
value: Type.String({
description: "Machine-readable value returned for this option",
}),
label: Type.String({ description: "Short option title shown in the list" }),
description: Type.Optional(
Type.String({ description: "Optional subtitle/description" }),
),
preview: Type.Optional(
Type.String({
description: "Optional preview text, used by richer question variants",
}),
),
});
export const AskQuestionSchema = Type.Object({
id: Type.String({ description: "Unique question identifier" }),
label: Type.Optional(
Type.String({ description: "Short tab label, e.g. Scope or Runtime" }),
),
prompt: Type.String({ description: "Full question prompt" }),
type: Type.Optional(
Type.Union(
[
Type.Literal("single"),
Type.Literal("multi"),
Type.Literal("code-review"),
],
{ description: "Question type" },
),
),
allowOther: Type.Optional(
Type.Boolean({ description: "Include a fixed 'Type something.' option" }),
),
required: Type.Optional(
Type.Boolean({
description: "Whether an answer is required before submit",
}),
),
options: Type.Array(AskOptionSchema, {
description: "Available answer options",
}),
});
export const AskParamsSchema = Type.Object({
title: Type.Optional(
Type.String({ description: "Optional title shown above the interview" }),
),
questions: Type.Array(AskQuestionSchema, {
description: "Questions to ask the user",
}),
});
+370
View File
@@ -0,0 +1,370 @@
import type {
AskAnswer,
AskParams,
AskQuestion,
AskQuestionInput,
AskResult,
AskState,
RenderOption,
} from "./types.ts";
const OTHER_VALUE = "__other__";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
): AskQuestion {
return {
...question,
label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single",
allowOther: question.allowOther !== false,
required: question.required !== false,
};
}
export function createInitialState(params: AskParams): AskState {
return {
title: params.title,
questions: normalizeQuestions(params),
currentTab: 0,
optionIndex: 0,
submitIndex: 0,
mode: "navigate",
inputQuestionId: null,
answers: {},
completed: false,
cancelled: false,
};
}
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.currentTab];
}
export function isSubmitTab(state: AskState): boolean {
return state.currentTab >= state.questions.length;
}
export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
if (!question) return [];
const options: RenderOption[] = [...question.options];
if (question.allowOther) {
options.push({
value: OTHER_VALUE,
label: "Type something.",
isOther: true,
});
}
return options;
}
export function getAnswer(
state: AskState,
questionId: string,
): AskAnswer | undefined {
return state.answers[questionId];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
): boolean {
const answer = state.answers[questionId];
return !!answer && answer.labels.length > 0;
}
export function allRequiredAnswered(state: AskState): boolean {
return state.questions.every(
(question) => !question.required || isQuestionAnswered(state, question.id),
);
}
export function moveTab(state: AskState, delta: number): AskState {
if (state.questions.length <= 1 && delta !== 0) {
return state;
}
const totalTabs = state.questions.length + 1;
const currentTab = (state.currentTab + delta + totalTabs) % totalTabs;
return {
...state,
currentTab,
optionIndex: 0,
submitIndex: 0,
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
inputQuestionId: null,
};
}
export function moveOption(state: AskState, delta: number): AskState {
if (isSubmitTab(state)) {
return {
...state,
submitIndex: clamp(state.submitIndex + delta, 0, 1),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
optionIndex: clamp(
state.optionIndex + delta,
0,
Math.max(0, options.length - 1),
),
};
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return {
...state,
mode: "input",
inputQuestionId: questionId,
};
}
export function exitInputMode(state: AskState): AskState {
return {
...state,
mode: isSubmitTab(state) ? "submit" : "navigate",
inputQuestionId: null,
};
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") return state;
const options = getRenderableOptions(question);
const option = options[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
return toggleAnswer(state, question.id, option, state.optionIndex);
}
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
if (state.submitIndex === 1) {
return { ...state, cancelled: true, completed: true };
}
if (allRequiredAnswered(state)) {
return { ...state, completed: true };
}
return state;
}
const question = getCurrentQuestion(state);
if (!question) return state;
if (question.type === "multi") {
const currentOption = getRenderableOptions(question)[state.optionIndex];
if (currentOption?.isOther) {
return enterInputMode(state, question.id);
}
return advanceToNextTab(state);
}
const option = getRenderableOptions(question)[state.optionIndex];
if (!option) return state;
if (option.isOther) {
return enterInputMode(state, question.id);
}
const nextState = setSingleAnswer(
state,
question.id,
option,
state.optionIndex,
);
return advanceToNextTab(nextState);
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (digit <= 0 || isSubmitTab(state)) return state;
const question = getCurrentQuestion(state);
if (!question) return state;
const options = getRenderableOptions(question);
const index = digit - 1;
const option = options[index];
if (!option) return state;
const selectedState = { ...state, optionIndex: index };
if (option.isOther) {
return enterInputMode(selectedState, question.id);
}
if (question.type === "multi") {
return toggleAnswer(selectedState, question.id, option, index);
}
const nextState = setSingleAnswer(selectedState, question.id, option, index);
return advanceToNextTab(nextState);
}
export function submitCustomAnswer(
state: AskState,
rawValue: string,
): AskState {
const question = state.questions.find(
(item) => item.id === state.inputQuestionId,
);
if (!question) return exitInputMode(state);
const trimmed = rawValue.trim();
if (!trimmed) {
return exitInputMode(state);
}
const answer: AskAnswer = {
values: [trimmed],
labels: [trimmed],
indices: [],
customText: trimmed,
};
return advanceToNextTab({
...exitInputMode(state),
answers: {
...state.answers,
[question.id]: answer,
},
});
}
export function cancelFlow(state: AskState): AskState {
if (state.mode === "input") {
return exitInputMode(state);
}
return {
...state,
cancelled: true,
completed: true,
};
}
export function toAskResult(state: AskState): AskResult {
return {
title: state.title,
cancelled: state.cancelled,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
})),
answers: state.answers,
};
}
export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return "User cancelled the ask flow";
}
const lines = result.questions.flatMap((question) => {
const answer = result.answers[question.id];
if (!answer) return [];
if (answer.customText) {
return [`${question.label}: ${answer.customText}`];
}
return [`${question.label}: ${answer.labels.join(", ")}`];
});
return lines.join("\n") || "User submitted the ask flow";
}
function toggleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
const next = cloneAnswer(existing);
const foundIndex = next.values.indexOf(option.value);
if (foundIndex >= 0) {
next.values.splice(foundIndex, 1);
next.labels.splice(foundIndex, 1);
next.indices.splice(foundIndex, 1);
} else {
next.values.push(option.value);
next.labels.push(option.label);
next.indices.push(index + 1);
}
return {
...state,
answers: {
...state.answers,
[questionId]: next,
},
};
}
function setSingleAnswer(
state: AskState,
questionId: string,
option: RenderOption,
index: number,
): AskState {
return {
...state,
answers: {
...state.answers,
[questionId]: {
values: [option.value],
labels: [option.label],
indices: [index + 1],
},
},
};
}
function advanceToNextTab(state: AskState): AskState {
const totalQuestions = state.questions.length;
if (totalQuestions === 1) {
return {
...state,
currentTab: totalQuestions,
mode: "submit",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
};
}
const nextTab = Math.min(state.currentTab + 1, totalQuestions);
return {
...state,
currentTab: nextTab,
mode: nextTab === totalQuestions ? "submit" : "navigate",
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
};
}
function emptyAnswer(): AskAnswer {
return { values: [], labels: [], indices: [] };
}
function cloneAnswer(answer: AskAnswer): AskAnswer {
return {
values: [...answer.values],
labels: [...answer.labels],
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+70
View File
@@ -0,0 +1,70 @@
export type AskQuestionType = "single" | "multi" | "code-review";
export interface AskOption {
value: string;
label: string;
description?: string;
preview?: string;
}
export interface AskQuestionInput {
id: string;
label?: string;
prompt: string;
type?: AskQuestionType;
allowOther?: boolean;
required?: boolean;
options: AskOption[];
}
export interface AskParams {
title?: string;
questions: AskQuestionInput[];
}
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "allowOther" | "required" | "label"> {
label: string;
type: AskQuestionType;
allowOther: boolean;
required: boolean;
}
export interface AskAnswer {
values: string[];
labels: string[];
indices: number[];
customText?: string;
note?: string;
}
export interface AskResult {
title?: string;
cancelled: boolean;
questions: Array<{
id: string;
label: string;
prompt: string;
type: AskQuestionType;
}>;
answers: Record<string, AskAnswer>;
}
export type ViewMode = "navigate" | "input" | "submit";
export interface AskState {
title?: string;
questions: AskQuestion[];
currentTab: number;
optionIndex: number;
submitIndex: number;
mode: ViewMode;
inputQuestionId: string | null;
answers: Record<string, AskAnswer>;
completed: boolean;
cancelled: boolean;
}
export interface RenderOption extends AskOption {
isOther?: boolean;
}
+66
View File
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderResultText } from "../src/result.ts";
import {
applyNumberShortcut,
createInitialState,
submitCustomAnswer,
summarizeResult,
toAskResult,
} from "../src/state.ts";
test("summarizeResult formats selected answers", () => {
let state = createInitialState({
questions: [
{
id: "style",
label: "Style",
prompt: "How should I frame it?",
options: [
{ value: "minimal", label: "Minimal" },
{ value: "rich", label: "Rich" },
],
},
],
});
state = applyNumberShortcut(state, 2);
const result = toAskResult(state);
assert.equal(summarizeResult(result), "Style: Rich");
assert.equal(renderResultText(result), "✓ Style: Rich");
});
test("summaries include custom text answers", () => {
let state = createInitialState({
questions: [
{
id: "notes",
label: "Notes",
prompt: "Anything else?",
options: [{ value: "none", label: "None" }],
},
],
});
state = applyNumberShortcut(state, 2);
state = submitCustomAnswer(state, "Please include examples");
const result = toAskResult(state);
assert.equal(summarizeResult(result), "Notes: Please include examples");
assert.equal(
renderResultText(result),
"✓ Notes: (wrote) Please include examples",
);
});
test("cancelled result renders as cancelled", () => {
const result = {
title: "Interview",
cancelled: true,
questions: [],
answers: {},
};
assert.equal(renderResultText(result), "Cancelled");
});
+136
View File
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
allRequiredAnswered,
applyNumberShortcut,
cancelFlow,
confirmCurrentSelection,
createInitialState,
getRenderableOptions,
moveOption,
moveTab,
submitCustomAnswer,
toggleCurrentMultiOption,
} from "../src/state.ts";
import type { AskParams } from "../src/types.ts";
function sampleParams(): AskParams {
return {
title: "Interview",
questions: [
{
id: "lang",
label: "Language",
prompt: "What language?",
options: [
{ value: "py", label: "Python" },
{ value: "ts", label: "TypeScript" },
],
},
{
id: "fe",
label: "Frontend",
prompt: "Which frameworks?",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
};
}
test("normalize defaults via initial state", () => {
const state = createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
});
assert.equal(state.questions[0].label, "Q1");
assert.equal(state.questions[0].type, "single");
assert.equal(state.questions[0].allowOther, true);
assert.equal(state.questions[0].required, true);
assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.label,
"Type something.",
);
});
test("single-select number shortcut stores answer and advances to next tab", () => {
let state = createInitialState(sampleParams());
state = applyNumberShortcut(state, 2);
assert.equal(state.answers.lang.labels[0], "TypeScript");
assert.equal(state.currentTab, 1);
assert.equal(state.mode, "navigate");
});
test("multi-select space toggles current option and enter advances to submit", () => {
let state = createInitialState(sampleParams());
state = applyNumberShortcut(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.fe.labels, ["React"]);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.fe.labels, ["React", "Vue"]);
state = confirmCurrentSelection(state);
assert.equal(state.mode, "submit");
assert.equal(state.currentTab, 2);
assert.equal(allRequiredAnswered(state), true);
});
test("selecting other enters input mode and custom submit stores typed answer", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Other?",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
assert.equal(state.mode, "input");
state = submitCustomAnswer(state, "my custom answer");
assert.equal(state.answers.q1.customText, "my custom answer");
assert.equal(state.mode, "submit");
});
test("tab navigation wraps including submit tab", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);
assert.equal(state.currentTab, 2);
assert.equal(state.mode, "submit");
state = moveTab(state, 1);
assert.equal(state.currentTab, 0);
assert.equal(state.mode, "navigate");
});
test("escape cancels from main flow but only exits input mode when typing", () => {
let state = createInitialState(sampleParams());
state = cancelFlow(state);
assert.equal(state.cancelled, true);
assert.equal(state.completed, true);
state = createInitialState({
questions: [
{ id: "q", prompt: "Q?", options: [{ value: "a", label: "A" }] },
],
});
state = applyNumberShortcut(state, 2);
assert.equal(state.mode, "input");
state = cancelFlow(state);
assert.equal(state.mode, "navigate");
assert.equal(state.cancelled, false);
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}