feat(ask): add initial ask tool prototype and project setup
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.zed/private.json
|
||||
@@ -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}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2604
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user