diff --git a/README.md b/README.md index fdb59e0..9a815b7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # @eko24ive/pi-ask [![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@eko24ive/pi-ask) -[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=0145055)](https://github.com/eko24ive/pi-ask/commits/main) +[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=6fef4ac)](https://github.com/eko24ive/pi-ask/commits/main) [![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers) > [!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. - 🧭 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 - 📎 Native pi-style `@` file references inside answer and note editors - 📝 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. -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 diff --git a/docs/contract.md b/docs/contract.md index 23c3cb1..9cdefdf 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -20,6 +20,7 @@ This document defines the stable external behavior. It does not explain internal label: string; description?: 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 - 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 +- `recommended` is optional presentation metadata; zero, one, or multiple options may set it to `true` +- recommended options render warning-colored `(recommended)` followed by muted ` | `, or only `(recommended)` when no description exists, but are not preselected - `type` defaults to `single` - `required` defaults to `false` - `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; description?: string; preview?: string; + recommended?: boolean; }>; }; answered: boolean; @@ -133,6 +137,7 @@ This document defines the stable external behavior. It does not explain internal label: string; description?: string; preview?: string; + recommended?: boolean; }>; }; option: { @@ -140,6 +145,7 @@ This document defines the stable external behavior. It does not explain internal label: string; description?: string; preview?: string; + recommended?: boolean; }; selected: 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.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered` - 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 - `indices` are 1-based rendered option positions - `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 - 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` - inline free-form answers for all question types - native pi-style `@` file path autocomplete inside free-form answer and note editors diff --git a/docs/remote-events.md b/docs/remote-events.md index 30e361d..dedabf9 100644 --- a/docs/remote-events.md +++ b/docs/remote-events.md @@ -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 diff --git a/src/answer-extraction.ts b/src/answer-extraction.ts index 607651d..922a665 100644 --- a/src/answer-extraction.ts +++ b/src/answer-extraction.ts @@ -39,6 +39,7 @@ Rules: - Never mix a freeform option with normal options. - Do not create an option that merely restates the question. - Include descriptions only when helpful. +- Never add recommendation metadata. - If there are no questions, call ask_user with {"questions":[]}.`; interface ExtractionPromptOptions { diff --git a/src/ask-tool-helpers.ts b/src/ask-tool-helpers.ts index 45df036..df9cdba 100644 --- a/src/ask-tool-helpers.ts +++ b/src/ask-tool-helpers.ts @@ -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`, 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`, 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`, 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.", diff --git a/src/constants/ui.ts b/src/constants/ui.ts index 177470c..7c78ab9 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -14,6 +14,7 @@ export const UI_DIMENSIONS = { } as const; export const UI_TEXT = { + recommendedMarker: "(recommended)", questionNoteTitle: "Note:", reviewTitle: "Review answers", unanswered: "→ unanswered", diff --git a/src/schema.ts b/src/schema.ts index d2a0e25..3ad4cd8 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -19,6 +19,12 @@ export const AskOptionSchema = Type.Object({ "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({ diff --git a/src/state/normalize.ts b/src/state/normalize.ts index 6bca186..342bb4c 100644 --- a/src/state/normalize.ts +++ b/src/state/normalize.ts @@ -112,6 +112,9 @@ function normalizeOption(option: AskOption): AskOption { label: option.label.trim(), description: option.description?.trim() || undefined, preview: option.preview?.trim() || undefined, + ...(option.recommended === undefined + ? {} + : { recommended: option.recommended }), ...(option.freeform ? { freeform: true } : {}), }; } diff --git a/src/state/result.ts b/src/state/result.ts index cd02dc2..509b6cc 100644 --- a/src/state/result.ts +++ b/src/state/result.ts @@ -205,6 +205,9 @@ function cloneOption(option: AskState["questions"][number]["options"][number]) { label: option.label, ...(option.description ? { description: option.description } : {}), ...(option.preview ? { preview: option.preview } : {}), + ...(option.recommended === undefined + ? {} + : { recommended: option.recommended }), }; } diff --git a/src/types.ts b/src/types.ts index 3d72396..0e01bd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface AskOption { freeform?: boolean; label: string; preview?: string; + recommended?: boolean; value: string; } diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index 85edf0b..a42f0b1 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -311,16 +311,23 @@ export function mergeColumns( } export function measurePreviewLeftWidth( - options: Array<{ label: string; description?: string }>, + options: Array<{ + description?: string; + label: string; + recommended?: boolean; + }>, width: number ): number { let widest = 0; for (let index = 0; index < options.length; index++) { const option = options[index]; + const description = option.recommended + ? `${UI_TEXT.recommendedMarker}${option.description ? ` | ${option.description}` : ""}` + : option.description; widest = Math.max( widest, visibleWidth(`${index + 1}. ${option.label}`), - option.description ? visibleWidth(option.description) : 0 + description ? visibleWidth(description) : 0 ); } diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index 7cc42b7..d91e453 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -1,5 +1,9 @@ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import { UI_DIMENSIONS } from "../constants/ui.ts"; +import { + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts"; import { measurePreviewLeftWidth, mergeColumns, @@ -83,7 +87,13 @@ function renderStandardOption( 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, { suppressLeadingGap: !!row.description, }); @@ -170,7 +180,7 @@ function renderPreviewOptionList( row.pointer, " " ); - renderOptionDescription(lines, row.description, width, theme); + renderOptionSubtitle(lines, row.description, row.recommended, width, theme); } return lines; } @@ -275,14 +285,36 @@ function renderInteractiveCustomOption( }); } -function renderOptionDescription( +function renderOptionSubtitle( lines: string[], description: string | undefined, + recommended: boolean, width: number, theme: Theme ) { - if (!description) { + if (!recommended) { + if (description) { + pushWrappedText( + lines, + description, + width, + theme, + "muted", + " ", + " " + ); + } 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)); + } } diff --git a/src/ui/view-models/question.ts b/src/ui/view-models/question.ts index a4a9e9b..60786dd 100644 --- a/src/ui/view-models/question.ts +++ b/src/ui/view-models/question.ts @@ -30,6 +30,7 @@ export interface OptionRowModel { label: string; pointer: string; prefix: string; + recommended: boolean; selected: boolean; } @@ -104,6 +105,7 @@ function buildOptionRowModel( label: option.label, pointer, prefix: getOptionPrefix(question.type, option, answered), + recommended: option.recommended === true, selected, }; } diff --git a/tests/answer-extraction.test.ts b/tests/answer-extraction.test.ts index 22affeb..9215356 100644 --- a/tests/answer-extraction.test.ts +++ b/tests/answer-extraction.test.ts @@ -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?.length, 1); + assert.equal( + (context.systemPrompt ?? "").includes("Never add recommendation metadata."), + true + ); const prompt = JSON.stringify(context.messages[0]?.content); assert.equal(prompt.includes("Choose an implementation direction."), true); assert.equal(prompt.includes("Which direction: A or B?"), true); diff --git a/tests/ask-payload-store.test.ts b/tests/ask-payload-store.test.ts index c84c7d1..25936a8 100644 --- a/tests/ask-payload-store.test.ts +++ b/tests/ask-payload-store.test.ts @@ -8,7 +8,7 @@ const params: AskParams = { { id: "goal", prompt: "Goal?", - options: [{ value: "a", label: "A" }], + options: [{ value: "a", label: "A", recommended: true }], }, ], }; diff --git a/tests/ask-tool.test.ts b/tests/ask-tool.test.ts index ee7ee56..2576d0e 100644 --- a/tests/ask-tool.test.ts +++ b/tests/ask-tool.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { validateToolArguments } from "@earendil-works/pi-ai/base"; +import { Value } from "typebox/value"; import { registerAskTool } from "../src/ask-tool.ts"; +import { AskOptionSchema } from "../src/schema.ts"; import type { AskParams } from "../src/types.ts"; const NON_INTERACTIVE_MESSAGE_RE = @@ -41,6 +43,7 @@ function registerMockTool() { execute: (...args: any[]) => Promise; parameters: Record; prepareArguments: (args: unknown) => unknown; + promptGuidelines: string[]; renderCall: (args: unknown, theme: any) => { text: string }; renderResult: ( 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 () => { const { entries, tool } = registerMockTool(); const params = sampleParams(); diff --git a/tests/remote-ask.test.ts b/tests/remote-ask.test.ts index 51d241c..6aea45d 100644 --- a/tests/remote-ask.test.ts +++ b/tests/remote-ask.test.ts @@ -152,7 +152,7 @@ test("started event questions are cloned", async () => { { id: "decision", prompt: "Proceed?", - options: [{ value: "yes", label: "Yes" }], + options: [{ value: "yes", label: "Yes", recommended: true }], }, ], }).questions; @@ -167,9 +167,12 @@ test("started event questions are cloned", async () => { const started = bus.events.find( (event) => event.channel === PI_ASK_STARTED_EVENT )?.data as RemoteAskStartedEvent; + assert.equal(started.questions[0].options[0].recommended, true); 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].recommended, true); }); test("remote runtime disposes active flows and submit listener", () => { diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index 1244fd6..184d323 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -9,13 +9,14 @@ import { } from "../src/state/transitions.ts"; import { renderQuestionScreen } from "../src/ui/render-question.ts"; -function mockTheme() { +function mockTheme(onColor?: (color: string, text: string) => void) { return { fg(color: string, text: string) { - return `<${color}>${text}`; + onColor?.(color, text); + return onColor ? text : `<${color}>${text}`; }, bg(color: string, text: string) { - return `{${color}}${text}{/${color}}`; + return onColor ? text : `{${color}}${text}{/${color}}`; }, bold(text: string) { return text; @@ -60,6 +61,56 @@ test("custom option stays labeled before selection", () => { 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", () => { let state = createInitialState({ questions: [ @@ -230,30 +281,48 @@ test("freeform-only question renders label without numbering or pointer and sepa 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({ questions: [ { id: "q1", prompt: "Pick one", type: "preview", - options: [{ value: "a", label: "A", preview: "Preview A" }], + options: [ + { + value: "a", + label: "A", + preview: "Preview A", + recommended: true, + }, + ], }, ], }); 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(), + theme: mockTheme((color, text) => calls.push({ color, text })), width: 80, }); 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", () => { diff --git a/tests/result.test.ts b/tests/result.test.ts index 8686d8c..bad26ce 100644 --- a/tests/result.test.ts +++ b/tests/result.test.ts @@ -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", () => { const result = { title: "Interview", diff --git a/tests/state.test.ts b/tests/state.test.ts index e1fdfa6..06a0200 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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", () => { let state = createInitialState(sampleParams()); state = applyNumberShortcut(state, 2);