feat: mark recommended ask options
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# @eko24ive/pi-ask
|
# @eko24ive/pi-ask
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@eko24ive/pi-ask)
|
[](https://www.npmjs.com/package/@eko24ive/pi-ask)
|
||||||
[](https://github.com/eko24ive/pi-ask/commits/main)
|
[](https://github.com/eko24ive/pi-ask/commits/main)
|
||||||
[](https://github.com/eko24ive/pi-ask/stargazers)
|
[](https://github.com/eko24ive/pi-ask/stargazers)
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
@@ -48,6 +48,7 @@ pi -e npm:@eko24ive/pi-ask
|
|||||||
Once installed, this package gives the agent a native way to ask for clarification instead of guessing.
|
Once installed, this package gives the agent a native way to ask for clarification instead of guessing.
|
||||||
|
|
||||||
- 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode
|
- 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode
|
||||||
|
- ⭐ Optional warning-colored `(recommended)` markers that do not preselect answers
|
||||||
- ✍️ Inline free-form `Type your own` answers
|
- ✍️ Inline free-form `Type your own` answers
|
||||||
- 📎 Native pi-style `@` file references inside answer and note editors
|
- 📎 Native pi-style `@` file references inside answer and note editors
|
||||||
- 📝 Question-level and option-level notes
|
- 📝 Question-level and option-level notes
|
||||||
@@ -198,7 +199,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
|
|||||||
|
|
||||||
After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands.
|
After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands.
|
||||||
|
|
||||||
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately when the config file is writable; save failures revert the toggle and show a manual-edit message. The settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path.
|
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. They can mark any number of grounded preferences with `recommended: true` and use option descriptions for reasons. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately when the config file is writable; save failures revert the toggle and show a manual-edit message. The settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path.
|
||||||
|
|
||||||
### Answer and replay commands
|
### Answer and replay commands
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
recommended?: boolean;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,8 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- blank optional `title`, question `label`, option `description`, and option `preview` fields are treated as omitted
|
- blank optional `title`, question `label`, option `description`, and option `preview` fields are treated as omitted
|
||||||
- question `label` falls back to `Q1`, `Q2`, ...
|
- question `label` falls back to `Q1`, `Q2`, ...
|
||||||
- option `label` is required in the public schema; before schema validation, a missing or blank string label is derived from a non-empty `value` by replacing hyphens and underscores with spaces and capitalizing the first character
|
- option `label` is required in the public schema; before schema validation, a missing or blank string label is derived from a non-empty `value` by replacing hyphens and underscores with spaces and capitalizing the first character
|
||||||
|
- `recommended` is optional presentation metadata; zero, one, or multiple options may set it to `true`
|
||||||
|
- recommended options render warning-colored `(recommended)` followed by muted ` | <description>`, or only `(recommended)` when no description exists, but are not preselected
|
||||||
- `type` defaults to `single`
|
- `type` defaults to `single`
|
||||||
- `required` defaults to `false`
|
- `required` defaults to `false`
|
||||||
- `required` is metadata only; it never blocks submission
|
- `required` is metadata only; it never blocks submission
|
||||||
@@ -107,6 +110,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
recommended?: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
answered: boolean;
|
answered: boolean;
|
||||||
@@ -133,6 +137,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
recommended?: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
option: {
|
option: {
|
||||||
@@ -140,6 +145,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
recommended?: boolean;
|
||||||
};
|
};
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
answered: boolean;
|
answered: boolean;
|
||||||
@@ -172,6 +178,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- `continuation.affectedQuestionIds` lists the only questions that should be revisited
|
- `continuation.affectedQuestionIds` lists the only questions that should be revisited
|
||||||
- `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered`
|
- `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered`
|
||||||
- single-select answers still use arrays
|
- single-select answers still use arrays
|
||||||
|
- recommendation markers never change canonical submitted labels or values
|
||||||
- when `behaviour.presentSingleAsMulti` is enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requested `type`, adds `presentedType` when final presentation differs, and result text uses one compact note when any answered questions were presented differently
|
- when `behaviour.presentSingleAsMulti` is enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requested `type`, adds `presentedType` when final presentation differs, and result text uses one compact note when any answered questions were presented differently
|
||||||
- `indices` are 1-based rendered option positions
|
- `indices` are 1-based rendered option positions
|
||||||
- `customText` stores the free-form answer
|
- `customText` stores the free-form answer
|
||||||
@@ -199,6 +206,7 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
|
|
||||||
- tabbed multi-question flow
|
- tabbed multi-question flow
|
||||||
- single-select, multi-select, and preview questions
|
- single-select, multi-select, and preview questions
|
||||||
|
- optional warning-colored recommendation subtitles in standard and preview option lists without automatic selection
|
||||||
- active question type changes via configurable `main.changeQuestionType` hotkey, default `t`; non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
|
- active question type changes via configurable `main.changeQuestionType` hotkey, default `t`; non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
|
||||||
- inline free-form answers for all question types
|
- inline free-form answers for all question types
|
||||||
- native pi-style `@` file path autocomplete inside free-form answer and note editors
|
- native pi-style `@` file path autocomplete inside free-form answer and note editors
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type PiAskStartedEvent = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. `ask:resume` identifies a form recovered from an interrupted tool call.
|
Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. `ask:resume` identifies a form recovered from an interrupted tool call. Started-event options preserve the public optional `recommended` boolean, which is presentation metadata only and never changes remote submission values or labels.
|
||||||
|
|
||||||
## Submit an answer
|
## Submit an answer
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Rules:
|
|||||||
- Never mix a freeform option with normal options.
|
- Never mix a freeform option with normal options.
|
||||||
- Do not create an option that merely restates the question.
|
- Do not create an option that merely restates the question.
|
||||||
- Include descriptions only when helpful.
|
- Include descriptions only when helpful.
|
||||||
|
- Never add recommendation metadata.
|
||||||
- If there are no questions, call ask_user with {"questions":[]}.`;
|
- If there are no questions, call ask_user with {"questions":[]}.`;
|
||||||
|
|
||||||
interface ExtractionPromptOptions {
|
interface ExtractionPromptOptions {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
|
|||||||
"When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
"When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
||||||
"When calling `ask_user`, always include a stable `id` and non-empty `prompt` for every question.",
|
"When calling `ask_user`, always include a stable `id` and non-empty `prompt` for every question.",
|
||||||
"When calling `ask_user`, always include a non-empty machine-readable `value` and visible `label` for every option.",
|
"When calling `ask_user`, always include a non-empty machine-readable `value` and visible `label` for every option.",
|
||||||
|
"When calling `ask_user`, mark grounded preferences with `recommended: true` and use the option `description` to state the reason.",
|
||||||
"When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
|
"When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
|
||||||
'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
||||||
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const UI_DIMENSIONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const UI_TEXT = {
|
export const UI_TEXT = {
|
||||||
|
recommendedMarker: "(recommended)",
|
||||||
questionNoteTitle: "Note:",
|
questionNoteTitle: "Note:",
|
||||||
reviewTitle: "Review answers",
|
reviewTitle: "Review answers",
|
||||||
unanswered: "→ unanswered",
|
unanswered: "→ unanswered",
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export const AskOptionSchema = Type.Object({
|
|||||||
"Optional preview content shown in the dedicated preview pane for preview questions",
|
"Optional preview content shown in the dedicated preview pane for preview questions",
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
recommended: Type.Optional(
|
||||||
|
Type.Boolean({
|
||||||
|
description:
|
||||||
|
"Optional presentation marker for a grounded preference; use the description to explain the reason",
|
||||||
|
})
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AskQuestionSchema = Type.Object({
|
export const AskQuestionSchema = Type.Object({
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ function normalizeOption(option: AskOption): AskOption {
|
|||||||
label: option.label.trim(),
|
label: option.label.trim(),
|
||||||
description: option.description?.trim() || undefined,
|
description: option.description?.trim() || undefined,
|
||||||
preview: option.preview?.trim() || undefined,
|
preview: option.preview?.trim() || undefined,
|
||||||
|
...(option.recommended === undefined
|
||||||
|
? {}
|
||||||
|
: { recommended: option.recommended }),
|
||||||
...(option.freeform ? { freeform: true } : {}),
|
...(option.freeform ? { freeform: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ function cloneOption(option: AskState["questions"][number]["options"][number]) {
|
|||||||
label: option.label,
|
label: option.label,
|
||||||
...(option.description ? { description: option.description } : {}),
|
...(option.description ? { description: option.description } : {}),
|
||||||
...(option.preview ? { preview: option.preview } : {}),
|
...(option.preview ? { preview: option.preview } : {}),
|
||||||
|
...(option.recommended === undefined
|
||||||
|
? {}
|
||||||
|
: { recommended: option.recommended }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AskOption {
|
|||||||
freeform?: boolean;
|
freeform?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
recommended?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,16 +311,23 @@ export function mergeColumns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function measurePreviewLeftWidth(
|
export function measurePreviewLeftWidth(
|
||||||
options: Array<{ label: string; description?: string }>,
|
options: Array<{
|
||||||
|
description?: string;
|
||||||
|
label: string;
|
||||||
|
recommended?: boolean;
|
||||||
|
}>,
|
||||||
width: number
|
width: number
|
||||||
): number {
|
): number {
|
||||||
let widest = 0;
|
let widest = 0;
|
||||||
for (let index = 0; index < options.length; index++) {
|
for (let index = 0; index < options.length; index++) {
|
||||||
const option = options[index];
|
const option = options[index];
|
||||||
|
const description = option.recommended
|
||||||
|
? `${UI_TEXT.recommendedMarker}${option.description ? ` | ${option.description}` : ""}`
|
||||||
|
: option.description;
|
||||||
widest = Math.max(
|
widest = Math.max(
|
||||||
widest,
|
widest,
|
||||||
visibleWidth(`${index + 1}. ${option.label}`),
|
visibleWidth(`${index + 1}. ${option.label}`),
|
||||||
option.description ? visibleWidth(option.description) : 0
|
description ? visibleWidth(description) : 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
import {
|
||||||
import { UI_DIMENSIONS } from "../constants/ui.ts";
|
truncateToWidth,
|
||||||
|
visibleWidth,
|
||||||
|
wrapTextWithAnsi,
|
||||||
|
} from "@earendil-works/pi-tui";
|
||||||
|
import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts";
|
||||||
import {
|
import {
|
||||||
measurePreviewLeftWidth,
|
measurePreviewLeftWidth,
|
||||||
mergeColumns,
|
mergeColumns,
|
||||||
@@ -83,7 +87,13 @@ function renderStandardOption(
|
|||||||
row.pointer,
|
row.pointer,
|
||||||
" ".repeat(visibleWidth(row.pointer))
|
" ".repeat(visibleWidth(row.pointer))
|
||||||
);
|
);
|
||||||
renderOptionDescription(lines, row.description, context.width, context.theme);
|
renderOptionSubtitle(
|
||||||
|
lines,
|
||||||
|
row.description,
|
||||||
|
row.recommended,
|
||||||
|
context.width,
|
||||||
|
context.theme
|
||||||
|
);
|
||||||
renderOptionDetail(lines, row.detail, context, {
|
renderOptionDetail(lines, row.detail, context, {
|
||||||
suppressLeadingGap: !!row.description,
|
suppressLeadingGap: !!row.description,
|
||||||
});
|
});
|
||||||
@@ -170,7 +180,7 @@ function renderPreviewOptionList(
|
|||||||
row.pointer,
|
row.pointer,
|
||||||
" "
|
" "
|
||||||
);
|
);
|
||||||
renderOptionDescription(lines, row.description, width, theme);
|
renderOptionSubtitle(lines, row.description, row.recommended, width, theme);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
@@ -275,14 +285,36 @@ function renderInteractiveCustomOption(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOptionDescription(
|
function renderOptionSubtitle(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
description: string | undefined,
|
description: string | undefined,
|
||||||
|
recommended: boolean,
|
||||||
width: number,
|
width: number,
|
||||||
theme: Theme
|
theme: Theme
|
||||||
) {
|
) {
|
||||||
if (!description) {
|
if (!recommended) {
|
||||||
|
if (description) {
|
||||||
|
pushWrappedText(
|
||||||
|
lines,
|
||||||
|
description,
|
||||||
|
width,
|
||||||
|
theme,
|
||||||
|
"muted",
|
||||||
|
" ",
|
||||||
|
" "
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
|
|
||||||
|
const indent = " ";
|
||||||
|
const text =
|
||||||
|
theme.fg("warning", UI_TEXT.recommendedMarker) +
|
||||||
|
(description ? theme.fg("muted", ` | ${description}`) : "");
|
||||||
|
for (const line of wrapTextWithAnsi(
|
||||||
|
text,
|
||||||
|
Math.max(1, width - visibleWidth(indent))
|
||||||
|
)) {
|
||||||
|
lines.push(truncateToWidth(`${indent}${line}`, width));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface OptionRowModel {
|
|||||||
label: string;
|
label: string;
|
||||||
pointer: string;
|
pointer: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
recommended: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ function buildOptionRowModel(
|
|||||||
label: option.label,
|
label: option.label,
|
||||||
pointer,
|
pointer,
|
||||||
prefix: getOptionPrefix(question.type, option, answered),
|
prefix: getOptionPrefix(question.type, option, answered),
|
||||||
|
recommended: option.recommended === true,
|
||||||
selected,
|
selected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ test("extraction context includes the tool and preceding turn", () => {
|
|||||||
|
|
||||||
assert.equal(context.tools?.[0]?.name, "ask_user");
|
assert.equal(context.tools?.[0]?.name, "ask_user");
|
||||||
assert.equal(context.tools?.length, 1);
|
assert.equal(context.tools?.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
(context.systemPrompt ?? "").includes("Never add recommendation metadata."),
|
||||||
|
true
|
||||||
|
);
|
||||||
const prompt = JSON.stringify(context.messages[0]?.content);
|
const prompt = JSON.stringify(context.messages[0]?.content);
|
||||||
assert.equal(prompt.includes("Choose an implementation direction."), true);
|
assert.equal(prompt.includes("Choose an implementation direction."), true);
|
||||||
assert.equal(prompt.includes("Which direction: A or B?"), true);
|
assert.equal(prompt.includes("Which direction: A or B?"), true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const params: AskParams = {
|
|||||||
{
|
{
|
||||||
id: "goal",
|
id: "goal",
|
||||||
prompt: "Goal?",
|
prompt: "Goal?",
|
||||||
options: [{ value: "a", label: "A" }],
|
options: [{ value: "a", label: "A", recommended: true }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { validateToolArguments } from "@earendil-works/pi-ai/base";
|
import { validateToolArguments } from "@earendil-works/pi-ai/base";
|
||||||
|
import { Value } from "typebox/value";
|
||||||
import { registerAskTool } from "../src/ask-tool.ts";
|
import { registerAskTool } from "../src/ask-tool.ts";
|
||||||
|
import { AskOptionSchema } from "../src/schema.ts";
|
||||||
import type { AskParams } from "../src/types.ts";
|
import type { AskParams } from "../src/types.ts";
|
||||||
|
|
||||||
const NON_INTERACTIVE_MESSAGE_RE =
|
const NON_INTERACTIVE_MESSAGE_RE =
|
||||||
@@ -41,6 +43,7 @@ function registerMockTool() {
|
|||||||
execute: (...args: any[]) => Promise<any>;
|
execute: (...args: any[]) => Promise<any>;
|
||||||
parameters: Record<string, any>;
|
parameters: Record<string, any>;
|
||||||
prepareArguments: (args: unknown) => unknown;
|
prepareArguments: (args: unknown) => unknown;
|
||||||
|
promptGuidelines: string[];
|
||||||
renderCall: (args: unknown, theme: any) => { text: string };
|
renderCall: (args: unknown, theme: any) => { text: string };
|
||||||
renderResult: (
|
renderResult: (
|
||||||
result: any,
|
result: any,
|
||||||
@@ -72,6 +75,27 @@ function sampleParams(): AskParams {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("ask option schema and tool guidance support grounded recommendations", () => {
|
||||||
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Value.Check(AskOptionSchema, {
|
||||||
|
value: "small",
|
||||||
|
label: "Small",
|
||||||
|
description: "Lowest implementation risk",
|
||||||
|
recommended: true,
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tool.promptGuidelines.some(
|
||||||
|
(guideline) =>
|
||||||
|
guideline.includes("grounded preferences") &&
|
||||||
|
guideline.includes("description")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("ask tool stores valid payloads as soon as they are called", async () => {
|
test("ask tool stores valid payloads as soon as they are called", async () => {
|
||||||
const { entries, tool } = registerMockTool();
|
const { entries, tool } = registerMockTool();
|
||||||
const params = sampleParams();
|
const params = sampleParams();
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ test("started event questions are cloned", async () => {
|
|||||||
{
|
{
|
||||||
id: "decision",
|
id: "decision",
|
||||||
prompt: "Proceed?",
|
prompt: "Proceed?",
|
||||||
options: [{ value: "yes", label: "Yes" }],
|
options: [{ value: "yes", label: "Yes", recommended: true }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).questions;
|
}).questions;
|
||||||
@@ -167,9 +167,12 @@ test("started event questions are cloned", async () => {
|
|||||||
const started = bus.events.find(
|
const started = bus.events.find(
|
||||||
(event) => event.channel === PI_ASK_STARTED_EVENT
|
(event) => event.channel === PI_ASK_STARTED_EVENT
|
||||||
)?.data as RemoteAskStartedEvent;
|
)?.data as RemoteAskStartedEvent;
|
||||||
|
assert.equal(started.questions[0].options[0].recommended, true);
|
||||||
started.questions[0].options[0].label = "Mutated";
|
started.questions[0].options[0].label = "Mutated";
|
||||||
|
started.questions[0].options[0].recommended = false;
|
||||||
|
|
||||||
assert.equal(questions[0].options[0].label, "Yes");
|
assert.equal(questions[0].options[0].label, "Yes");
|
||||||
|
assert.equal(questions[0].options[0].recommended, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("remote runtime disposes active flows and submit listener", () => {
|
test("remote runtime disposes active flows and submit listener", () => {
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
} from "../src/state/transitions.ts";
|
} from "../src/state/transitions.ts";
|
||||||
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
||||||
|
|
||||||
function mockTheme() {
|
function mockTheme(onColor?: (color: string, text: string) => void) {
|
||||||
return {
|
return {
|
||||||
fg(color: string, text: string) {
|
fg(color: string, text: string) {
|
||||||
return `<${color}>${text}</${color}>`;
|
onColor?.(color, text);
|
||||||
|
return onColor ? text : `<${color}>${text}</${color}>`;
|
||||||
},
|
},
|
||||||
bg(color: string, text: string) {
|
bg(color: string, text: string) {
|
||||||
return `{${color}}${text}{/${color}}`;
|
return onColor ? text : `{${color}}${text}{/${color}}`;
|
||||||
},
|
},
|
||||||
bold(text: string) {
|
bold(text: string) {
|
||||||
return text;
|
return text;
|
||||||
@@ -60,6 +61,56 @@ test("custom option stays labeled before selection", () => {
|
|||||||
assert(!lines.some((line) => line.includes("Type your answer...")));
|
assert(!lines.some((line) => line.includes("Type your answer...")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("standard options show recommendation metadata without changing answers", () => {
|
||||||
|
let state = createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Pick one",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "a",
|
||||||
|
label: "Option A",
|
||||||
|
description: "Best fit for the stated constraints",
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{ value: "b", label: "Option B" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const calls: Array<{ color: string; text: string }> = [];
|
||||||
|
renderQuestionScreen({
|
||||||
|
editor: mockEditor(),
|
||||||
|
lines,
|
||||||
|
options: getRenderableOptions(state.questions[0]),
|
||||||
|
question: state.questions[0],
|
||||||
|
state,
|
||||||
|
theme: mockTheme((color, text) => calls.push({ color, text })),
|
||||||
|
width: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(lines.some((line) => line.includes("(recommended) | Best fit")));
|
||||||
|
assert(
|
||||||
|
calls.some(
|
||||||
|
(call) => call.color === "warning" && call.text === "(recommended)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
calls.some(
|
||||||
|
(call) =>
|
||||||
|
call.color === "muted" &&
|
||||||
|
call.text === " | Best fit for the stated constraints"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal(state.answers.q1, undefined);
|
||||||
|
|
||||||
|
state = applyNumberShortcut(state, 1);
|
||||||
|
assert.equal(state.answers.q1.selected[0]?.label, "Option A");
|
||||||
|
});
|
||||||
|
|
||||||
test("selected custom option keeps its label and renders editor below", () => {
|
test("selected custom option keeps its label and renders editor below", () => {
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
@@ -230,30 +281,48 @@ test("freeform-only question renders label without numbering or pointer and sepa
|
|||||||
assert(!lines.some((line) => line.includes("❯")));
|
assert(!lines.some((line) => line.includes("❯")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preview questions also show the custom answer option", () => {
|
test("preview questions show custom and recommended options", () => {
|
||||||
const state = createInitialState({
|
const state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
prompt: "Pick one",
|
prompt: "Pick one",
|
||||||
type: "preview",
|
type: "preview",
|
||||||
options: [{ value: "a", label: "A", preview: "Preview A" }],
|
options: [
|
||||||
|
{
|
||||||
|
value: "a",
|
||||||
|
label: "A",
|
||||||
|
preview: "Preview A",
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
const calls: Array<{ color: string; text: string }> = [];
|
||||||
renderQuestionScreen({
|
renderQuestionScreen({
|
||||||
editor: mockEditor(),
|
editor: mockEditor(),
|
||||||
lines,
|
lines,
|
||||||
options: getRenderableOptions(state.questions[0]),
|
options: getRenderableOptions(state.questions[0]),
|
||||||
question: state.questions[0],
|
question: state.questions[0],
|
||||||
state,
|
state,
|
||||||
theme: mockTheme(),
|
theme: mockTheme((color, text) => calls.push({ color, text })),
|
||||||
width: 80,
|
width: 80,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(lines.some((line) => line.includes("Type your own")));
|
assert(lines.some((line) => line.includes("Type your own")));
|
||||||
|
assert(lines.some((line) => line.trim() === "(recommended)"));
|
||||||
|
assert(
|
||||||
|
calls.some(
|
||||||
|
(call) => call.color === "warning" && call.text === "(recommended)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!calls.some((call) => call.color === "muted" && call.text.startsWith(" |"))
|
||||||
|
);
|
||||||
|
assert.equal(state.answers.q1, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preview custom option reuses the normal inline editor", () => {
|
test("preview custom option reuses the normal inline editor", () => {
|
||||||
|
|||||||
@@ -634,6 +634,29 @@ test("elaborate continuation preserves unrelated committed answers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("elaboration preserves recommendation metadata", () => {
|
||||||
|
let state = createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "framework",
|
||||||
|
label: "Framework",
|
||||||
|
prompt: "Pick a framework",
|
||||||
|
options: [{ value: "react", label: "React", recommended: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
state = enterOptionNoteMode(state, "framework", "react");
|
||||||
|
state = saveNote(state, "Explain this preference");
|
||||||
|
state = { ...state, mode: "elaborate" };
|
||||||
|
|
||||||
|
const item = toAskResult(state).elaboration?.items[0];
|
||||||
|
assert.equal(item?.question.options[0]?.recommended, true);
|
||||||
|
assert.equal(
|
||||||
|
item && "option" in item ? item.option.recommended : undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("invalid tool payload result is attributed to the tool call", () => {
|
test("invalid tool payload result is attributed to the tool call", () => {
|
||||||
const result = {
|
const result = {
|
||||||
title: "Interview",
|
title: "Interview",
|
||||||
|
|||||||
@@ -284,6 +284,28 @@ test("normalize treats blank optional presentation text as absent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalization preserves any number of recommended options without selecting them", () => {
|
||||||
|
const state = createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
prompt: "What scope?",
|
||||||
|
options: [
|
||||||
|
{ value: "small", label: "Small", recommended: true },
|
||||||
|
{ value: "medium", label: "Medium", recommended: false },
|
||||||
|
{ value: "large", label: "Large", recommended: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
state.questions[0].options.map((option) => option.recommended),
|
||||||
|
[true, false, true]
|
||||||
|
);
|
||||||
|
assert.deepEqual(state.answers, {});
|
||||||
|
});
|
||||||
|
|
||||||
test("single-select number shortcut stores answer and advances to next tab", () => {
|
test("single-select number shortcut stores answer and advances to next tab", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = applyNumberShortcut(state, 2);
|
state = applyNumberShortcut(state, 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user