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