feat: add answer extraction replay commands
This commit is contained in:
@@ -36,7 +36,11 @@ Once installed, this package gives the agent a native way to ask for clarificati
|
|||||||
- 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
|
- 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
|
||||||
- 💬 Elaboration flow to capture note-based clarification before final submission
|
- 💬 Elaboration flow to capture note-based clarification before final submission
|
||||||
- ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions)
|
- ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions)
|
||||||
- ⚙️ Ask settings with persisted behaviour and keymap customization via config
|
- ⚙️ Ask settings with persisted behaviour, keymap customization, and `/answer` extraction config
|
||||||
|
- 🔁 Slash commands for fallback/replay:
|
||||||
|
- `/answer` extracts questions from the latest assistant message into an ask flow
|
||||||
|
- `/answer:again` reopens the latest `/answer` form on the current branch
|
||||||
|
- `/ask:replay` replays the latest real `ask_user` form on the current branch
|
||||||
- 🗣️ You can talk to your agent to configure pi-ask; it will read the bundled configuration guide and tailor the config for you
|
- 🗣️ You can talk to your agent to configure pi-ask; it will read the bundled configuration guide and tailor the config for you
|
||||||
|
|
||||||
## Feature walkthrough
|
## Feature walkthrough
|
||||||
@@ -129,7 +133,16 @@ You can edit this file yourself, ask pi to edit it for you, or use `/ask-setting
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 2,
|
||||||
|
"answer": {
|
||||||
|
"extractionModels": [
|
||||||
|
{ "provider": "openai-codex", "id": "gpt-5.4-mini" },
|
||||||
|
{ "provider": "github-copilot", "id": "gpt-5.4-mini" },
|
||||||
|
{ "provider": "anthropic", "id": "claude-haiku-4-5" }
|
||||||
|
],
|
||||||
|
"extractionTimeoutMs": 30000,
|
||||||
|
"extractionRetries": 1
|
||||||
|
},
|
||||||
"behaviour": {
|
"behaviour": {
|
||||||
"autoSubmitWhenAnsweredWithoutNotes": false,
|
"autoSubmitWhenAnsweredWithoutNotes": false,
|
||||||
"confirmDismissWhenDirty": true,
|
"confirmDismissWhenDirty": true,
|
||||||
@@ -151,10 +164,21 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
|
|||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
After installation, the extension registers the `ask_user` tool and `/ask-settings` command.
|
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 settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path.
|
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 settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path.
|
||||||
|
|
||||||
|
### Answer and replay commands
|
||||||
|
|
||||||
|
`/answer` is useful when the agent asked questions in plain text instead of using `ask_user`. It extracts questions from the latest completed assistant message and opens the same ask UI.
|
||||||
|
|
||||||
|
Replay commands are branch-aware. They read persisted entries from the current pi session branch, so they work naturally with `/resume`, `/tree`, and conversation branching:
|
||||||
|
|
||||||
|
- `/answer:again` reopens the latest form created by `/answer` on this branch
|
||||||
|
- `/ask:replay` reopens the latest real `ask_user` form on this branch
|
||||||
|
|
||||||
|
Cancellation is local to the UI: closing a replayed form does not start a new agent turn. Submitted answers are sent back as a normal user follow-up message.
|
||||||
|
|
||||||
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
|
You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly.
|
||||||
|
|
||||||
This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user.
|
This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ This folder keeps only the documentation needed to understand and maintain the e
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `configuration.md` — source of truth for configuring pi-ask keymaps and behaviour
|
- `configuration.md` — source of truth for configuring pi-ask keymaps, behaviour, and `/answer` extraction
|
||||||
- `contract.md` — external behavior, full tool payload/result details, and UX guarantees
|
- `contract.md` — external behavior, full tool payload/result details, and UX guarantees
|
||||||
- `architecture.md` — module boundaries and invariants
|
- `architecture.md` — module boundaries and invariants
|
||||||
- `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user
|
- `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
### Tool surface
|
### Tool surface
|
||||||
|
|
||||||
- `src/index.ts` — extension entrypoint
|
- `src/index.ts` — extension entrypoint
|
||||||
- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering
|
- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering, ask payload capture
|
||||||
|
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
|
||||||
|
- `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries
|
||||||
|
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
|
||||||
- `src/schema.ts` — TypeBox schema
|
- `src/schema.ts` — TypeBox schema
|
||||||
- `src/types.ts` — shared types
|
- `src/types.ts` — shared types
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ The codebase is split so the implementation reads through file boundaries and na
|
|||||||
- editor lifecycle stays in the controller, not in the reducers
|
- editor lifecycle stays in the controller, not in the reducers
|
||||||
- persisted ask settings are migrated to the current schema version, validated, and normalized before use
|
- persisted ask settings are migrated to the current schema version, validated, and normalized before use
|
||||||
- config schema migrations preserve user-provided values and add new defaults only when fields are absent
|
- config schema migrations preserve user-provided values and add new defaults only when fields are absent
|
||||||
|
- replay payload lookup scans only the current session branch and revalidates payloads before use
|
||||||
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
|
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
|
||||||
- ask settings behaviour changes persist immediately from the settings list
|
- ask settings behaviour changes persist immediately from the settings list
|
||||||
- when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
|
- when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
|
||||||
|
|||||||
+44
-3
@@ -28,7 +28,16 @@ Unsupported future versions or invalid files are backed up and defaults are load
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 2,
|
||||||
|
"answer": {
|
||||||
|
"extractionModels": [
|
||||||
|
{ "provider": "openai-codex", "id": "gpt-5.4-mini" },
|
||||||
|
{ "provider": "github-copilot", "id": "gpt-5.4-mini" },
|
||||||
|
{ "provider": "anthropic", "id": "claude-haiku-4-5" }
|
||||||
|
],
|
||||||
|
"extractionTimeoutMs": 30000,
|
||||||
|
"extractionRetries": 1
|
||||||
|
},
|
||||||
"behaviour": {
|
"behaviour": {
|
||||||
"autoSubmitWhenAnsweredWithoutNotes": false,
|
"autoSubmitWhenAnsweredWithoutNotes": false,
|
||||||
"confirmDismissWhenDirty": true,
|
"confirmDismissWhenDirty": true,
|
||||||
@@ -46,6 +55,29 @@ Unsupported future versions or invalid files are backed up and defaults are load
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Answer extraction
|
||||||
|
|
||||||
|
These settings affect only the `/answer` command. Normal `ask_user` tool calls do not use an extraction model.
|
||||||
|
|
||||||
|
### `answer.extractionModels`
|
||||||
|
|
||||||
|
- type: array of `{ "provider": string, "id": string }`
|
||||||
|
- default: `openai-codex/gpt-5.4-mini`, `github-copilot/gpt-5.4-mini`, then `anthropic/claude-haiku-4-5`
|
||||||
|
- effect: `/answer` tries configured models in order and uses the first model with available auth
|
||||||
|
- fallback: if no configured model is usable, `/answer` tries the current chat model after validating its auth
|
||||||
|
|
||||||
|
### `answer.extractionTimeoutMs`
|
||||||
|
|
||||||
|
- type: positive number
|
||||||
|
- default: `30000`
|
||||||
|
- effect: per-attempt extraction timeout in milliseconds
|
||||||
|
|
||||||
|
### `answer.extractionRetries`
|
||||||
|
|
||||||
|
- type: integer from `0` to `3`
|
||||||
|
- default: `1`
|
||||||
|
- effect: number of retry attempts after raw JSON parsing fails; retries include the parse error and previous response as feedback
|
||||||
|
|
||||||
## Behaviour
|
## Behaviour
|
||||||
|
|
||||||
### `behaviour.autoSubmitWhenAnsweredWithoutNotes`
|
### `behaviour.autoSubmitWhenAnsweredWithoutNotes`
|
||||||
@@ -184,7 +216,14 @@ Invalid keymaps include:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 2,
|
||||||
|
"answer": {
|
||||||
|
"extractionRetries": 1,
|
||||||
|
"extractionTimeoutMs": 30000,
|
||||||
|
"extractionModels": [
|
||||||
|
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"behaviour": {
|
"behaviour": {
|
||||||
"autoSubmitWhenAnsweredWithoutNotes": true,
|
"autoSubmitWhenAnsweredWithoutNotes": true,
|
||||||
"confirmDismissWhenDirty": true,
|
"confirmDismissWhenDirty": true,
|
||||||
@@ -207,7 +246,9 @@ Invalid keymaps include:
|
|||||||
When editing this config for a user:
|
When editing this config for a user:
|
||||||
|
|
||||||
- preserve unrelated fields
|
- preserve unrelated fields
|
||||||
- keep `schemaVersion` at `1`
|
- keep `schemaVersion` at `2`
|
||||||
|
- preserve `answer.extractionModels` as explicit provider/id pairs
|
||||||
|
- keep `answer.extractionRetries` between `0` and `3`
|
||||||
- do not assign reserved bindings to configurable actions
|
- do not assign reserved bindings to configurable actions
|
||||||
- do not create duplicate configurable bindings
|
- do not create duplicate configurable bindings
|
||||||
- after changing the file, tell the user to run `/reload` or restart pi
|
- after changing the file, tell the user to run `/reload` or restart pi
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ This document defines the stable external behavior. It does not explain internal
|
|||||||
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
- on the review tab, `Submit` and `Cancel` preview notes only for answered questions
|
||||||
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
- on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options
|
||||||
- transcript-friendly call and result rendering
|
- transcript-friendly call and result rendering
|
||||||
|
- `/answer` command to extract a raw-JSON `AskParams` form from the latest completed assistant message and open the ask UI
|
||||||
|
- `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract
|
||||||
|
- `/answer:again` command to replay the latest `/answer`-extracted form on the current branch
|
||||||
|
- `/ask:replay` command to replay the latest real `ask_user` form on the current branch
|
||||||
- ask settings list with binary behaviour toggles
|
- ask settings list with binary behaviour toggles
|
||||||
- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay
|
- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay
|
||||||
- behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints`
|
- behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints`
|
||||||
@@ -241,6 +245,16 @@ Validation is handled inside the tool so malformed calls produce the same struct
|
|||||||
|
|
||||||
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
|
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
|
||||||
|
|
||||||
|
## Slash command replay/extraction
|
||||||
|
|
||||||
|
- valid `ask_user` payloads are persisted as branch custom entries before the UI opens, so `/ask:replay` can reopen them after cancel, `/resume`, or `/tree`
|
||||||
|
- `/answer` scans the current branch for the latest assistant message; if that message did not finish with `stop`, extraction is refused
|
||||||
|
- `/answer` expects the extractor to return raw JSON only; JSON parse failures are retried according to `answer.extractionRetries`, then reported to the user without opening the ask UI
|
||||||
|
- `{ "questions": [] }` from extraction means no questions were found and is not treated as an invalid ask payload
|
||||||
|
- command-flow cancellation closes with a notification and does not send a message to the agent
|
||||||
|
- submitted or elaborated command-flow results are sent back with user-message semantics
|
||||||
|
- replay commands scan only `ctx.sessionManager.getBranch()`, ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI
|
||||||
|
|
||||||
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
||||||
|
|
||||||
## Skill alignment (advisory)
|
## Skill alignment (advisory)
|
||||||
|
|||||||
+4
-2
@@ -84,7 +84,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "*",
|
"@mariozechner/pi-coding-agent": "*",
|
||||||
"@mariozechner/pi-tui": "*",
|
"@mariozechner/pi-tui": "*",
|
||||||
"typebox": "*"
|
"typebox": "*",
|
||||||
|
"@mariozechner/pi-ai": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.12",
|
"@biomejs/biome": "2.4.12",
|
||||||
@@ -105,7 +106,8 @@
|
|||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
"semantic-release": "^25.0.3",
|
"semantic-release": "^25.0.3",
|
||||||
"typescript": "6.0.2",
|
"typescript": "6.0.2",
|
||||||
"ultracite": "7.6.0"
|
"ultracite": "7.6.0",
|
||||||
|
"@mariozechner/pi-ai": "0.69.0"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
Generated
+3
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@commitlint/config-conventional':
|
'@commitlint/config-conventional':
|
||||||
specifier: ^19.8.1
|
specifier: ^19.8.1
|
||||||
version: 19.8.1
|
version: 19.8.1
|
||||||
|
'@mariozechner/pi-ai':
|
||||||
|
specifier: 0.69.0
|
||||||
|
version: 0.69.0(ws@8.20.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: 0.69.0
|
specifier: 0.69.0
|
||||||
version: 0.69.0(ws@8.20.0)(zod@4.3.6)
|
version: 0.69.0(ws@8.20.0)(zod@4.3.6)
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ After attempt 2:
|
|||||||
## ask_user payload quality
|
## ask_user payload quality
|
||||||
|
|
||||||
- Ask one concrete decision at a time.
|
- Ask one concrete decision at a time.
|
||||||
- Prefer 2-4 clear options (single-select by default).
|
- Provide clear, distinct options. Do not add filler options.
|
||||||
- Use `multi` only when selections are truly independent.
|
- Choose question type from semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail with non-empty preview text.
|
||||||
|
- Avoid defaulting mechanically; infer from whether options are mutually exclusive, can coexist, or need preview-pane detail.
|
||||||
- Keep option labels short and outcome-oriented.
|
- Keep option labels short and outcome-oriented.
|
||||||
- Include trade-off descriptions when non-obvious.
|
- Include trade-off descriptions when non-obvious.
|
||||||
- For research/planning, ask about goals, constraints, evaluation criteria, audience, budget, timeline, risk tolerance, and desired output only when they materially affect the result.
|
- For research/planning, ask about goals, constraints, evaluation criteria, audience, budget, timeline, risk tolerance, and desired output only when they materially affect the result.
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
BorderedLoader,
|
||||||
|
type ExtensionAPI,
|
||||||
|
type ExtensionCommandContext,
|
||||||
|
type ExtensionContext,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
extractAskParams,
|
||||||
|
selectExtractionModel,
|
||||||
|
} from "./answer-extraction.ts";
|
||||||
|
import {
|
||||||
|
type AskPayloadSource,
|
||||||
|
appendAskPayload,
|
||||||
|
findLatestPayloadInCurrentBranch,
|
||||||
|
} from "./ask-payload-store.ts";
|
||||||
|
import {
|
||||||
|
invalidPayloadResponse,
|
||||||
|
successfulResponse,
|
||||||
|
validateParams,
|
||||||
|
} from "./ask-tool-helpers.ts";
|
||||||
|
import { getAskConfigStore } from "./config/store.ts";
|
||||||
|
import type { AskParams, AskResult } from "./types.ts";
|
||||||
|
import { runAskFlow } from "./ui/controller.ts";
|
||||||
|
|
||||||
|
interface AssistantTextSource {
|
||||||
|
entryId: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractionSelection = Exclude<
|
||||||
|
Awaited<ReturnType<typeof selectExtractionModel>>,
|
||||||
|
{ error: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ExtractionUiResult =
|
||||||
|
| { cancelled: true }
|
||||||
|
| { error: string }
|
||||||
|
| { params: AskParams };
|
||||||
|
|
||||||
|
export function registerAnswerCommands(pi: ExtensionAPI): void {
|
||||||
|
pi.registerCommand("answer", {
|
||||||
|
description:
|
||||||
|
"Extract questions from the latest assistant message into an ask form",
|
||||||
|
handler: async (_args, ctx) => runAnswerCommand(pi, ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("answer:again", {
|
||||||
|
description: "Reopen the previous /answer form on this branch",
|
||||||
|
handler: async (_args, ctx) =>
|
||||||
|
runReplayCommand(pi, ctx, {
|
||||||
|
missingMessage:
|
||||||
|
"No previous /answer form found on this branch; use /answer first.",
|
||||||
|
noticePrefix: "Reopening previous /answer form on this branch",
|
||||||
|
source: "answer-extraction",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ask:replay", {
|
||||||
|
description: "Replay the previous ask_user form on this branch",
|
||||||
|
handler: async (_args, ctx) =>
|
||||||
|
runReplayCommand(pi, ctx, {
|
||||||
|
missingMessage: "No previous ask_user form found on this branch.",
|
||||||
|
noticePrefix: "Replaying previous ask_user form on this branch",
|
||||||
|
source: "tool",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnswerCommand(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
ctx: ExtensionCommandContext
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("/answer requires interactive UI.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = findLatestAssistantText(ctx);
|
||||||
|
if ("error" in assistant) {
|
||||||
|
ctx.ui.notify(assistant.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAskConfigStore().getConfig();
|
||||||
|
const selected = await selectExtractionModel(
|
||||||
|
ctx,
|
||||||
|
config.answer.extractionModels
|
||||||
|
);
|
||||||
|
if ("error" in selected) {
|
||||||
|
ctx.ui.notify(selected.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.usedFallback) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Configured extraction models unavailable; using current chat model ${selected.model.provider}/${selected.model.id}.`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await extractAndValidateAnswerParams(
|
||||||
|
ctx,
|
||||||
|
assistant,
|
||||||
|
selected,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
if (!params) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendAskPayload(pi, {
|
||||||
|
params,
|
||||||
|
source: "answer-extraction",
|
||||||
|
sourceEntryId: assistant.entryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAskAndSendSubmittedResult(pi, ctx, params, {
|
||||||
|
allowFreeform: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractAndValidateAnswerParams(
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
assistant: AssistantTextSource,
|
||||||
|
selected: ExtractionSelection,
|
||||||
|
config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||||
|
): Promise<AskParams | undefined> {
|
||||||
|
const extraction = await runExtractionUi(ctx, assistant, selected, config);
|
||||||
|
if ("cancelled" in extraction) {
|
||||||
|
ctx.ui.notify("Question extraction cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("error" in extraction) {
|
||||||
|
ctx.ui.notify(formatExtractionError(extraction.error), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAskParamsCandidate(extraction.params)) {
|
||||||
|
ctx.ui.notify("Question extraction returned an invalid ask form.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (extraction.params.questions.length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"No questions found in the latest assistant message.",
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validation = validateParams(extraction.params, { allowFreeform: true });
|
||||||
|
if (!validation.ok) {
|
||||||
|
const response = invalidPayloadResponse(
|
||||||
|
extraction.params,
|
||||||
|
validation.issues
|
||||||
|
);
|
||||||
|
ctx.ui.notify(response.content[0].text, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return extraction.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runExtractionUi(
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
assistant: AssistantTextSource,
|
||||||
|
selected: ExtractionSelection,
|
||||||
|
config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||||
|
): Promise<ExtractionUiResult> {
|
||||||
|
return ctx.ui.custom<ExtractionUiResult>((tui, theme, _keybindings, done) => {
|
||||||
|
let completed = false;
|
||||||
|
const doneOnce = (result: ExtractionUiResult) => {
|
||||||
|
if (completed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
completed = true;
|
||||||
|
done(result);
|
||||||
|
};
|
||||||
|
const loader = new BorderedLoader(
|
||||||
|
tui,
|
||||||
|
theme,
|
||||||
|
`Extracting questions using ${selected.model.provider}/${selected.model.id}...`
|
||||||
|
);
|
||||||
|
loader.onAbort = () => doneOnce({ cancelled: true });
|
||||||
|
extractAskParams({
|
||||||
|
assistantText: assistant.text,
|
||||||
|
auth: selected.auth,
|
||||||
|
model: selected.model,
|
||||||
|
onRetry: (attempt, maxRetries) => {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Retrying extraction JSON repair (${attempt}/${maxRetries})...`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
retries: config.answer.extractionRetries,
|
||||||
|
signal: loader.signal,
|
||||||
|
timeoutMs: config.answer.extractionTimeoutMs,
|
||||||
|
})
|
||||||
|
.then((params) => doneOnce({ params }))
|
||||||
|
.catch((error) => doneOnce({ error: formatErrorMessage(error) }));
|
||||||
|
return loader;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.name ? `${error.name}: ${error.message}` : error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAskParamsCandidate(value: unknown): value is AskParams {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
Array.isArray((value as { questions?: unknown }).questions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExtractionError(error: string): string {
|
||||||
|
if (error.includes("valid JSON")) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (error.includes("cancelled")) {
|
||||||
|
return "Question extraction cancelled.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.includes("aborted") ||
|
||||||
|
error.includes("AbortError") ||
|
||||||
|
error.includes("timed out")
|
||||||
|
) {
|
||||||
|
return "Question extraction timed out. Try again or configure a faster extraction model.";
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReplayCommand(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
options: {
|
||||||
|
missingMessage: string;
|
||||||
|
noticePrefix: string;
|
||||||
|
source: AskPayloadSource;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("Ask replay requires interactive UI.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lookup = findLatestPayloadInCurrentBranch(ctx, options.source);
|
||||||
|
if (!lookup.data) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
lookup.invalidMatchFound
|
||||||
|
? "Previous form exists on this branch but is no longer compatible."
|
||||||
|
: options.missingMessage,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.notify(
|
||||||
|
`${options.noticePrefix}: ${lookup.data.params.questions.length} question(s).`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, {
|
||||||
|
allowFreeform: options.source === "answer-extraction",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAskAndSendSubmittedResult(
|
||||||
|
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
params: AskParams,
|
||||||
|
options: { allowFreeform: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await runAskFlow(ctx, params, options);
|
||||||
|
if (result.cancelled) {
|
||||||
|
ctx.ui.notify("Ask form cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendAskResult(pi, result, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAskResult(
|
||||||
|
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
||||||
|
result: AskResult,
|
||||||
|
ctx: Pick<ExtensionContext, "isIdle">
|
||||||
|
): void {
|
||||||
|
const response = successfulResponse(result);
|
||||||
|
const text = response.content[0];
|
||||||
|
pi.sendUserMessage(
|
||||||
|
text.text,
|
||||||
|
ctx.isIdle() ? undefined : { deliverAs: "followUp" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLatestAssistantText(
|
||||||
|
ctx: Pick<ExtensionContext, "sessionManager">
|
||||||
|
): AssistantTextSource | { error: string } {
|
||||||
|
const branch = ctx.sessionManager.getBranch();
|
||||||
|
for (let index = branch.length - 1; index >= 0; index--) {
|
||||||
|
const entry = branch[index];
|
||||||
|
if (entry.type !== "message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const message = entry.message;
|
||||||
|
if (!("role" in message) || message.role !== "assistant") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.stopReason !== "stop") {
|
||||||
|
return {
|
||||||
|
error: `Latest assistant message is incomplete (${message.stopReason}); wait for it to finish, then run /answer again.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const text = message.content
|
||||||
|
.filter(
|
||||||
|
(part): part is { text: string; type: "text" } => part.type === "text"
|
||||||
|
)
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (text) {
|
||||||
|
return { entryId: entry.id, text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { error: "No assistant message found to extract questions from." };
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import {
|
||||||
|
type Api,
|
||||||
|
complete,
|
||||||
|
type Model,
|
||||||
|
type UserMessage,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
import type { AskConfig } from "./config/schema.ts";
|
||||||
|
import type { AskParams } from "./types.ts";
|
||||||
|
|
||||||
|
export const ANSWER_EXTRACTION_SYSTEM_PROMPT = `You extract user-input questions from an assistant message and return ONLY raw JSON.
|
||||||
|
|
||||||
|
Return JSON matching this TypeScript shape:
|
||||||
|
{
|
||||||
|
"title"?: string,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": string,
|
||||||
|
"label"?: string,
|
||||||
|
"prompt": string,
|
||||||
|
"type"?: "single" | "multi" | "preview",
|
||||||
|
"required"?: boolean,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": string,
|
||||||
|
"label": string,
|
||||||
|
"description"?: string,
|
||||||
|
"preview"?: string,
|
||||||
|
"freeform"?: boolean
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Output raw JSON only. No Markdown. No prose. No code fences.
|
||||||
|
- Extract questions that require user input.
|
||||||
|
- Ignore generic conversational or clarification prompts such as "How can I help?", "Could you clarify?", or "Let me know what you need" unless they include concrete choices.
|
||||||
|
- Extract a question when it has explicit choices or when the user should type their own answer.
|
||||||
|
- Preserve question order.
|
||||||
|
- Generate stable snake_case ids.
|
||||||
|
- Choose question type from the question semantics.
|
||||||
|
- Use type "single" when one answer is expected.
|
||||||
|
- Use type "multi" when multiple answers could reasonably be selected.
|
||||||
|
- Use type "preview" when options need preview-pane detail and every option has non-empty preview text.
|
||||||
|
- Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.
|
||||||
|
- Each extracted question must have at least one option.
|
||||||
|
- Options rule:
|
||||||
|
- Extract options only from choices explicitly offered by the assistant.
|
||||||
|
- If the assistant gives concrete choices, use those choices as normal options.
|
||||||
|
- If the assistant gives examples only, do not treat examples as choices unless they are presented as selectable answers.
|
||||||
|
- If no concrete choices are given and the user should type their own answer, create exactly one freeform option: {"value":"freeform","label":"Type answer","freeform":true}.
|
||||||
|
- Never invent options.
|
||||||
|
- Never mix a freeform option with normal options.
|
||||||
|
- Provide clear, distinct options. Do not add filler options.
|
||||||
|
- Do not create an option that merely restates the question.
|
||||||
|
- Include descriptions only when helpful.
|
||||||
|
- Return {"questions":[]} if no questions are found.`;
|
||||||
|
|
||||||
|
interface SelectedExtractionModel {
|
||||||
|
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||||
|
model: Model<Api>;
|
||||||
|
usedFallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectExtractionModel(
|
||||||
|
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
||||||
|
preferences: AskConfig["answer"]["extractionModels"]
|
||||||
|
): Promise<SelectedExtractionModel | { error: string }> {
|
||||||
|
for (const preference of preferences) {
|
||||||
|
const model = ctx.modelRegistry.find(preference.provider, preference.id);
|
||||||
|
if (!model) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||||
|
if (auth.ok) {
|
||||||
|
return { model, auth, usedFallback: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.model) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"No available extraction model. Configure answer.extractionModels or select a chat model.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
||||||
|
if (!auth.ok) {
|
||||||
|
return {
|
||||||
|
error: `No auth for fallback chat model: ${ctx.model.provider}/${ctx.model.id}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { model: ctx.model, auth, usedFallback: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractAskParams(options: {
|
||||||
|
assistantText: string;
|
||||||
|
model: Model<Api>;
|
||||||
|
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||||
|
retries: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs: number;
|
||||||
|
onRetry?: (attempt: number, maxRetries: number) => void;
|
||||||
|
}): Promise<AskParams> {
|
||||||
|
let lastCandidate: AskParams | undefined;
|
||||||
|
let lastResponse = "";
|
||||||
|
let lastError = "";
|
||||||
|
for (let attempt = 0; attempt <= options.retries; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
options.onRetry?.(attempt, options.retries);
|
||||||
|
}
|
||||||
|
const responseText = await runExtractionAttempt({
|
||||||
|
...options,
|
||||||
|
attempt,
|
||||||
|
lastError,
|
||||||
|
lastResponse,
|
||||||
|
});
|
||||||
|
lastResponse = responseText;
|
||||||
|
const parsed = parseExtractionCandidate(responseText);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
lastError = parsed.error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastCandidate = parsed.params;
|
||||||
|
if (parsed.issues.length === 0) {
|
||||||
|
return parsed.params;
|
||||||
|
}
|
||||||
|
lastError = parsed.issues.join("\n");
|
||||||
|
}
|
||||||
|
if (lastCandidate) {
|
||||||
|
return repairExtractionParams(lastCandidate);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Question extraction did not return valid JSON after retries."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runExtractionAttempt(options: {
|
||||||
|
assistantText: string;
|
||||||
|
attempt: number;
|
||||||
|
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||||
|
lastError: string;
|
||||||
|
lastResponse: string;
|
||||||
|
model: Model<Api>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, options.timeoutMs);
|
||||||
|
const abortFromParent = () => controller.abort();
|
||||||
|
options.signal?.addEventListener("abort", abortFromParent, { once: true });
|
||||||
|
try {
|
||||||
|
const userMessage: UserMessage = {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
options.attempt === 0
|
||||||
|
? options.assistantText
|
||||||
|
: formatRetryPrompt(options),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const response = await complete(
|
||||||
|
options.model,
|
||||||
|
{
|
||||||
|
systemPrompt: ANSWER_EXTRACTION_SYSTEM_PROMPT,
|
||||||
|
messages: [userMessage],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
apiKey: options.auth.apiKey,
|
||||||
|
headers: options.auth.headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.stopReason === "aborted") {
|
||||||
|
throw new Error(
|
||||||
|
timedOut
|
||||||
|
? "Question extraction timed out. Try again or configure a faster extraction model."
|
||||||
|
: "Question extraction cancelled."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.content
|
||||||
|
.filter(
|
||||||
|
(part): part is { text: string; type: "text" } => part.type === "text"
|
||||||
|
)
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n");
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
options.signal?.removeEventListener("abort", abortFromParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EXTRACTED_OPTIONS_PER_QUESTION = 4;
|
||||||
|
|
||||||
|
function parseExtractionCandidate(
|
||||||
|
responseText: string
|
||||||
|
):
|
||||||
|
| { ok: true; params: AskParams; issues: string[] }
|
||||||
|
| { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(responseText.trim()) as unknown;
|
||||||
|
if (!isAskParamsLike(parsed)) {
|
||||||
|
return { ok: false, error: "root.questions must be an array" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
params: parsed,
|
||||||
|
issues: collectExtractionBusinessIssues(parsed),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAskParamsLike(value: unknown): value is AskParams {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
Array.isArray((value as { questions?: unknown }).questions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectExtractionBusinessIssues(params: AskParams): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
params.questions.forEach((question, questionIndex) => {
|
||||||
|
if (isGenericConversationalPrompt(question.prompt)) {
|
||||||
|
issues.push(
|
||||||
|
`questions[${questionIndex}].prompt is generic conversational text; omit this question unless it includes concrete choices`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (question.options.length > MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
|
||||||
|
issues.push(
|
||||||
|
`questions[${questionIndex}].options has ${question.options.length} items; max is ${MAX_EXTRACTED_OPTIONS_PER_QUESTION}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
question.options.length === 1 &&
|
||||||
|
optionRestatesQuestion(question.options[0]?.label, question.prompt)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
`questions[${questionIndex}].options[0] merely restates the question; omit this question or provide meaningful distinct options`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairExtractionParams(params: AskParams): AskParams {
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
questions: params.questions
|
||||||
|
.filter((question) => !isGenericConversationalPrompt(question.prompt))
|
||||||
|
.filter(
|
||||||
|
(question) =>
|
||||||
|
question.options.length !== 1 ||
|
||||||
|
!optionRestatesQuestion(question.options[0]?.label, question.prompt)
|
||||||
|
)
|
||||||
|
.map((question) => ({
|
||||||
|
...question,
|
||||||
|
options: capOptionsPreservingOther(question.options),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function capOptionsPreservingOther<T extends { label: string; value: string }>(
|
||||||
|
options: T[]
|
||||||
|
): T[] {
|
||||||
|
if (options.length <= MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
const other = options.find((option) => isOtherOption(option));
|
||||||
|
if (!other) {
|
||||||
|
return options.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION);
|
||||||
|
}
|
||||||
|
const head = options
|
||||||
|
.filter((option) => option !== other)
|
||||||
|
.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION - 1);
|
||||||
|
return [...head, other];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOtherOption(option: { label: string; value: string }): boolean {
|
||||||
|
const label = normalizeText(option.label);
|
||||||
|
const value = normalizeText(option.value);
|
||||||
|
return (
|
||||||
|
label === "other" || label.includes("something else") || value === "other"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionRestatesQuestion(
|
||||||
|
label: string | undefined,
|
||||||
|
prompt: string
|
||||||
|
): boolean {
|
||||||
|
if (!label) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedLabel = normalizeText(label);
|
||||||
|
const normalizedPrompt = normalizeText(prompt);
|
||||||
|
return (
|
||||||
|
normalizedLabel.length > 8 &&
|
||||||
|
(normalizedPrompt.includes(normalizedLabel) ||
|
||||||
|
normalizedLabel.includes(normalizedPrompt))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericConversationalPrompt(prompt: string): boolean {
|
||||||
|
const normalized = normalizeText(prompt);
|
||||||
|
return [
|
||||||
|
"how can i help",
|
||||||
|
"could you clarify",
|
||||||
|
"let me know what you need",
|
||||||
|
"what else is on your mind",
|
||||||
|
"anything on your mind i can help with",
|
||||||
|
].some((phrase) => normalized.includes(phrase));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRetryPrompt(options: {
|
||||||
|
assistantText: string;
|
||||||
|
lastError: string;
|
||||||
|
lastResponse: string;
|
||||||
|
}): string {
|
||||||
|
return `Your previous response was not valid raw JSON.
|
||||||
|
|
||||||
|
JSON.parse error:
|
||||||
|
${options.lastError}
|
||||||
|
|
||||||
|
Previous response:
|
||||||
|
${options.lastResponse}
|
||||||
|
|
||||||
|
Original assistant message:
|
||||||
|
${options.assistantText}
|
||||||
|
|
||||||
|
Return ONLY valid raw JSON matching the schema and fix the reported issues. No Markdown. No prose.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionContext,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { validateParams } from "./ask-tool-helpers.ts";
|
||||||
|
import type { AskParams } from "./types.ts";
|
||||||
|
|
||||||
|
export const ASK_PAYLOAD_ENTRY_TYPE = "ask:payload";
|
||||||
|
export const ASK_PAYLOAD_ENTRY_VERSION = 1;
|
||||||
|
|
||||||
|
export type AskPayloadSource = "answer-extraction" | "tool";
|
||||||
|
|
||||||
|
export interface AskPayloadEntryData {
|
||||||
|
params: AskParams;
|
||||||
|
source: AskPayloadSource;
|
||||||
|
sourceEntryId?: string;
|
||||||
|
timestamp: number;
|
||||||
|
version: typeof ASK_PAYLOAD_ENTRY_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendAskPayload(
|
||||||
|
pi: Pick<ExtensionAPI, "appendEntry">,
|
||||||
|
data: Omit<AskPayloadEntryData, "timestamp" | "version">
|
||||||
|
): void {
|
||||||
|
pi.appendEntry(ASK_PAYLOAD_ENTRY_TYPE, {
|
||||||
|
version: ASK_PAYLOAD_ENTRY_VERSION,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLatestPayloadInCurrentBranch(
|
||||||
|
ctx: Pick<ExtensionContext, "sessionManager">,
|
||||||
|
source: AskPayloadSource
|
||||||
|
): { data?: AskPayloadEntryData; invalidMatchFound: boolean } {
|
||||||
|
let invalidMatchFound = false;
|
||||||
|
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
||||||
|
if (!isAskPayloadEntry(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const data = entry.data;
|
||||||
|
if (data?.source !== source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isValidAskPayloadData(data)) {
|
||||||
|
return { data, invalidMatchFound };
|
||||||
|
}
|
||||||
|
invalidMatchFound = true;
|
||||||
|
}
|
||||||
|
return { invalidMatchFound };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAskPayloadEntry(entry: unknown): entry is {
|
||||||
|
customType: string;
|
||||||
|
data?: Partial<AskPayloadEntryData>;
|
||||||
|
type: "custom";
|
||||||
|
} {
|
||||||
|
return (
|
||||||
|
!!entry &&
|
||||||
|
typeof entry === "object" &&
|
||||||
|
(entry as { type?: unknown }).type === "custom" &&
|
||||||
|
(entry as { customType?: unknown }).customType === ASK_PAYLOAD_ENTRY_TYPE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAskPayloadData(data: unknown): data is AskPayloadEntryData {
|
||||||
|
if (!(data && typeof data === "object")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = data as Partial<AskPayloadEntryData>;
|
||||||
|
if (payload.version !== ASK_PAYLOAD_ENTRY_VERSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (payload.source !== "tool" && payload.source !== "answer-extraction") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!payload.params ||
|
||||||
|
validateParams(payload.params, {
|
||||||
|
allowFreeform: payload.source === "answer-extraction",
|
||||||
|
}).ok === false
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+12
-6
@@ -18,28 +18,34 @@ export const ASK_TOOL_DESCRIPTION =
|
|||||||
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
||||||
"Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
"Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
||||||
"When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.",
|
"When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.",
|
||||||
"Prefer one focused decision per question and use short labels with 2-4 clear options.",
|
"Prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
||||||
"Always include a non-empty `value` for every option.",
|
"Always include a non-empty `value` for every option.",
|
||||||
'Use `type: "single"` by default and `type: "multi"` only when several answers can genuinely apply.',
|
"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 and every option includes non-empty `preview` text.",
|
||||||
'Use `type: "preview"` only when every option includes non-empty `preview` text for the dedicated preview pane. Option descriptions do not satisfy this requirement.',
|
"Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.",
|
||||||
|
'Use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
||||||
"After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
"After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
||||||
"When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.",
|
"When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.",
|
||||||
"Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.",
|
"Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
interface ValidateParamsOptions {
|
||||||
|
allowFreeform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateParams(
|
export function validateParams(
|
||||||
params: AskParams
|
params: AskParams,
|
||||||
|
options: ValidateParamsOptions = {}
|
||||||
):
|
):
|
||||||
| { ok: true; state: ReturnType<typeof createInitialState> }
|
| { ok: true; state: ReturnType<typeof createInitialState> }
|
||||||
| { ok: false; issues: AskValidationIssue[] } {
|
| { ok: false; issues: AskValidationIssue[] } {
|
||||||
const issues = collectValidationIssues(params);
|
const issues = collectValidationIssues(params, options);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
return { ok: false, issues };
|
return { ok: false, issues };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
state: createInitialState(params),
|
state: createInitialState(params, options),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -2,6 +2,7 @@ import type {
|
|||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { appendAskPayload } from "./ask-payload-store.ts";
|
||||||
import {
|
import {
|
||||||
ASK_TOOL_DESCRIPTION,
|
ASK_TOOL_DESCRIPTION,
|
||||||
ASK_TOOL_PROMPT_GUIDELINES,
|
ASK_TOOL_PROMPT_GUIDELINES,
|
||||||
@@ -25,16 +26,25 @@ export function registerAskTool(pi: ExtensionAPI) {
|
|||||||
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
||||||
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
||||||
parameters: AskParamsSchema,
|
parameters: AskParamsSchema,
|
||||||
execute: executeAskTool,
|
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||||
|
executeAskTool(
|
||||||
|
pi,
|
||||||
|
toolCallId,
|
||||||
|
params as AskParams,
|
||||||
|
signal,
|
||||||
|
onUpdate,
|
||||||
|
ctx
|
||||||
|
),
|
||||||
renderCall: renderAskToolCall,
|
renderCall: renderAskToolCall,
|
||||||
renderResult: renderAskToolResult,
|
renderResult: renderAskToolResult,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecuteContext extends Pick<ExtensionContext, "cwd" | "hasUI"> {}
|
interface ExecuteContext extends ExtensionContext {}
|
||||||
|
|
||||||
async function executeAskTool(
|
async function executeAskTool(
|
||||||
_toolCallId: string,
|
pi: Pick<ExtensionAPI, "appendEntry">,
|
||||||
|
toolCallId: string,
|
||||||
params: AskParams,
|
params: AskParams,
|
||||||
_signal: AbortSignal | undefined,
|
_signal: AbortSignal | undefined,
|
||||||
_onUpdate: unknown,
|
_onUpdate: unknown,
|
||||||
@@ -44,6 +54,11 @@ async function executeAskTool(
|
|||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
return invalidPayloadResponse(params, validation.issues);
|
return invalidPayloadResponse(params, validation.issues);
|
||||||
}
|
}
|
||||||
|
appendAskPayload(pi, {
|
||||||
|
params,
|
||||||
|
source: "tool",
|
||||||
|
sourceEntryId: toolCallId,
|
||||||
|
});
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) {
|
||||||
return nonInteractiveResponse(validation.state);
|
return nonInteractiveResponse(validation.state);
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-4
@@ -1,7 +1,20 @@
|
|||||||
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
|
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
|
||||||
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
|
||||||
|
|
||||||
|
const DEFAULT_EXTRACTION_RETRIES = 1;
|
||||||
|
const MAX_EXTRACTION_RETRIES = 3;
|
||||||
|
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export const DEFAULT_ASK_CONFIG: AskConfig = {
|
export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||||
|
answer: {
|
||||||
|
extractionModels: [
|
||||||
|
{ provider: "openai-codex", id: "gpt-5.4-mini" },
|
||||||
|
{ provider: "github-copilot", id: "gpt-5.4-mini" },
|
||||||
|
{ provider: "anthropic", id: "claude-haiku-4-5" },
|
||||||
|
],
|
||||||
|
extractionRetries: DEFAULT_EXTRACTION_RETRIES,
|
||||||
|
extractionTimeoutMs: DEFAULT_EXTRACTION_TIMEOUT_MS,
|
||||||
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
confirmDismissWhenDirty: true,
|
confirmDismissWhenDirty: true,
|
||||||
@@ -14,9 +27,24 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeAskConfig(
|
export function normalizeAskConfig(
|
||||||
config?: Partial<AskConfigFileV1> | AskConfig
|
config?: Partial<AskConfigFileV2> | AskConfig
|
||||||
): AskConfig {
|
): AskConfig {
|
||||||
return {
|
return {
|
||||||
|
answer: {
|
||||||
|
extractionModels:
|
||||||
|
config?.answer?.extractionModels?.filter(isValidModelPreference) ??
|
||||||
|
DEFAULT_ASK_CONFIG.answer.extractionModels,
|
||||||
|
extractionRetries: clampInteger(
|
||||||
|
config?.answer?.extractionRetries,
|
||||||
|
0,
|
||||||
|
MAX_EXTRACTION_RETRIES,
|
||||||
|
DEFAULT_EXTRACTION_RETRIES
|
||||||
|
),
|
||||||
|
extractionTimeoutMs: positiveNumberOrDefault(
|
||||||
|
config?.answer?.extractionTimeoutMs,
|
||||||
|
DEFAULT_EXTRACTION_TIMEOUT_MS
|
||||||
|
),
|
||||||
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes:
|
autoSubmitWhenAnsweredWithoutNotes:
|
||||||
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
|
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
|
||||||
@@ -38,10 +66,15 @@ export function normalizeAskConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
|
export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 {
|
||||||
const normalized = normalizeAskConfig(config);
|
const normalized = normalizeAskConfig(config);
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
answer: {
|
||||||
|
extractionModels: normalized.answer.extractionModels,
|
||||||
|
extractionRetries: normalized.answer.extractionRetries,
|
||||||
|
extractionTimeoutMs: normalized.answer.extractionTimeoutMs,
|
||||||
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes:
|
autoSubmitWhenAnsweredWithoutNotes:
|
||||||
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||||
@@ -60,3 +93,35 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidModelPreference(value: unknown): value is {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
} {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof (value as { id?: unknown }).id === "string" &&
|
||||||
|
(value as { id: string }).id.trim().length > 0 &&
|
||||||
|
typeof (value as { provider?: unknown }).provider === "string" &&
|
||||||
|
(value as { provider: string }).provider.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(
|
||||||
|
value: unknown,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
fallback: number
|
||||||
|
): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveNumberOrDefault(value: unknown, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
type AskConfigVersionMigrationResult,
|
type AskConfigVersionMigrationResult,
|
||||||
migrateAskConfigFileToCurrent,
|
migrateAskConfigFileToCurrent,
|
||||||
} from "./migrations/index.ts";
|
} from "./migrations/index.ts";
|
||||||
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
|
||||||
import { validateAskConfigFileV1 } from "./schema.ts";
|
import { validateAskConfigFileV2 } from "./schema.ts";
|
||||||
|
|
||||||
export class AskConfigMigrationError extends Error {
|
export class AskConfigMigrationError extends Error {
|
||||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||||
@@ -42,14 +42,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateAskConfigFileV1.Check(migratedFile.config)) {
|
if (!validateAskConfigFileV2.Check(migratedFile.config)) {
|
||||||
throw new AskConfigMigrationError(
|
throw new AskConfigMigrationError(
|
||||||
"Config was invalid or unsupported.",
|
"Config was invalid or unsupported.",
|
||||||
"invalid_or_unsupported"
|
"invalid_or_unsupported"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFile = migratedFile.config as AskConfigFileV1;
|
const currentFile = migratedFile.config as AskConfigFileV2;
|
||||||
const config = normalizeAskConfig(currentFile);
|
const config = normalizeAskConfig(currentFile);
|
||||||
const keymapsResult = normalizeConfiguredKeymaps(
|
const keymapsResult = normalizeConfiguredKeymaps(
|
||||||
currentFile.keymaps as
|
currentFile.keymaps as
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
||||||
|
|
||||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 1;
|
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [];
|
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||||
|
{
|
||||||
|
from: 1,
|
||||||
|
to: 2,
|
||||||
|
migrate: (config) => ({
|
||||||
|
...config,
|
||||||
|
schemaVersion: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export class AskConfigVersionMigrationError extends Error {
|
export class AskConfigVersionMigrationError extends Error {
|
||||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||||
|
|||||||
+36
-6
@@ -1,8 +1,22 @@
|
|||||||
import { type Static, Type } from "typebox";
|
import { type Static, Type } from "typebox";
|
||||||
import { Compile } from "typebox/compile";
|
import { Compile } from "typebox/compile";
|
||||||
|
|
||||||
export const AskConfigFileV1Schema = Type.Object({
|
const AskAnswerModelPreferenceSchema = Type.Object({
|
||||||
schemaVersion: Type.Literal(1),
|
id: Type.String(),
|
||||||
|
provider: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AskConfigFileV2Schema = Type.Object({
|
||||||
|
schemaVersion: Type.Literal(2),
|
||||||
|
answer: Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
extractionModels: Type.Optional(
|
||||||
|
Type.Array(AskAnswerModelPreferenceSchema)
|
||||||
|
),
|
||||||
|
extractionRetries: Type.Optional(Type.Number()),
|
||||||
|
extractionTimeoutMs: Type.Optional(Type.Number()),
|
||||||
|
})
|
||||||
|
),
|
||||||
behaviour: Type.Optional(
|
behaviour: Type.Optional(
|
||||||
Type.Object({
|
Type.Object({
|
||||||
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
||||||
@@ -14,16 +28,27 @@ export const AskConfigFileV1Schema = Type.Object({
|
|||||||
keymaps: Type.Optional(
|
keymaps: Type.Optional(
|
||||||
Type.Object({
|
Type.Object({
|
||||||
cancel: Type.Optional(Type.String()),
|
cancel: Type.Optional(Type.String()),
|
||||||
dismiss: Type.Optional(Type.String()),
|
|
||||||
toggle: Type.Optional(Type.String()),
|
|
||||||
confirm: Type.Optional(Type.String()),
|
confirm: Type.Optional(Type.String()),
|
||||||
|
dismiss: Type.Optional(Type.String()),
|
||||||
optionNote: Type.Optional(Type.String()),
|
optionNote: Type.Optional(Type.String()),
|
||||||
questionNote: Type.Optional(Type.String()),
|
questionNote: Type.Optional(Type.String()),
|
||||||
|
toggle: Type.Optional(Type.String()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AskConfigFileV1 = Static<typeof AskConfigFileV1Schema>;
|
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema>;
|
||||||
|
export type AskConfigFileV1 = Omit<
|
||||||
|
AskConfigFileV2,
|
||||||
|
"answer" | "schemaVersion"
|
||||||
|
> & {
|
||||||
|
schemaVersion: 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AskAnswerModelPreference {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AskConfigKeymaps {
|
export interface AskConfigKeymaps {
|
||||||
cancel: string;
|
cancel: string;
|
||||||
@@ -35,6 +60,11 @@ export interface AskConfigKeymaps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AskConfig {
|
export interface AskConfig {
|
||||||
|
answer: {
|
||||||
|
extractionModels: AskAnswerModelPreference[];
|
||||||
|
extractionRetries: number;
|
||||||
|
extractionTimeoutMs: number;
|
||||||
|
};
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
||||||
confirmDismissWhenDirty: boolean;
|
confirmDismissWhenDirty: boolean;
|
||||||
@@ -44,4 +74,4 @@ export interface AskConfig {
|
|||||||
keymaps: AskConfigKeymaps;
|
keymaps: AskConfigKeymaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema);
|
export const validateAskConfigFileV2 = Compile(AskConfigFileV2Schema);
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_ASK_CONFIG,
|
DEFAULT_ASK_CONFIG,
|
||||||
normalizeAskConfig,
|
normalizeAskConfig,
|
||||||
toAskConfigFileV1,
|
toAskConfigFileV2,
|
||||||
} from "./defaults.ts";
|
} from "./defaults.ts";
|
||||||
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
||||||
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
|
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
|
||||||
@@ -63,10 +63,10 @@ export class AskConfigStore {
|
|||||||
return (await this.ensureLoaded()).config;
|
return (await this.ensureLoaded()).config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(config: AskConfig): Promise<AskConfig> {
|
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
|
||||||
const normalized = normalizeAskConfig(config);
|
const normalized = normalizeAskConfig(config);
|
||||||
const content = JSON.stringify(
|
const content = JSON.stringify(
|
||||||
toAskConfigFileV1(normalized),
|
toAskConfigFileV2(normalized),
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
).concat("\n");
|
).concat("\n");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { registerAnswerCommands } from "./answer-commands.ts";
|
||||||
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
||||||
import { registerAskTool } from "./ask-tool.ts";
|
import { registerAskTool } from "./ask-tool.ts";
|
||||||
|
|
||||||
@@ -18,4 +19,5 @@ export default function askExtension(pi: ExtensionAPI) {
|
|||||||
}));
|
}));
|
||||||
registerAskTool(pi);
|
registerAskTool(pi);
|
||||||
registerAskSettingsCommand(pi);
|
registerAskSettingsCommand(pi);
|
||||||
|
registerAnswerCommands(pi);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -46,7 +46,7 @@ export const AskQuestionSchema = Type.Object({
|
|||||||
type: Type.Optional(
|
type: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description:
|
description:
|
||||||
"Question type: use `single` by default. Use `preview` only when every option includes `preview` text for the dedicated preview pane; descriptions alone are not enough.",
|
"Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
required: Type.Optional(
|
required: Type.Optional(
|
||||||
@@ -56,7 +56,8 @@ export const AskQuestionSchema = Type.Object({
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
options: Type.Array(AskOptionSchema, {
|
options: Type.Array(AskOptionSchema, {
|
||||||
description: "Answer options; prefer 2-4 clear choices when possible",
|
description:
|
||||||
|
"Answer options; provide clear, distinct choices and do not add filler options",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -2,9 +2,16 @@ import type { AskParams } from "../types.ts";
|
|||||||
import { normalizeQuestions } from "./normalize.ts";
|
import { normalizeQuestions } from "./normalize.ts";
|
||||||
import { createInitialState as createBaseState } from "./transitions.ts";
|
import { createInitialState as createBaseState } from "./transitions.ts";
|
||||||
|
|
||||||
export function createInitialState(params: AskParams) {
|
interface CreateInitialStateOptions {
|
||||||
|
allowFreeform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState(
|
||||||
|
params: AskParams,
|
||||||
|
options: CreateInitialStateOptions = {}
|
||||||
|
) {
|
||||||
return createBaseState({
|
return createBaseState({
|
||||||
title: params.title,
|
title: params.title,
|
||||||
questions: normalizeQuestions(params),
|
questions: normalizeQuestions(params, options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,5 +62,9 @@ export function syncStateToSelection(state: AskState): AskState {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getAnswer(state, question.id)?.customText?.trim()) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
return enterInputMode(state, question.id);
|
return enterInputMode(state, question.id);
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-7
@@ -11,8 +11,15 @@ interface IssueCollector {
|
|||||||
issues: AskValidationIssue[];
|
issues: AskValidationIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeQuestions(params: AskParams): AskQuestion[] {
|
interface ValidationOptions {
|
||||||
const issues = collectValidationIssues(params);
|
allowFreeform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeQuestions(
|
||||||
|
params: AskParams,
|
||||||
|
options: ValidationOptions = {}
|
||||||
|
): AskQuestion[] {
|
||||||
|
const issues = collectValidationIssues(params, options);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
||||||
}
|
}
|
||||||
@@ -20,10 +27,11 @@ export function normalizeQuestions(params: AskParams): AskQuestion[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function collectValidationIssues(
|
export function collectValidationIssues(
|
||||||
params: AskParams
|
params: AskParams,
|
||||||
|
options: ValidationOptions = {}
|
||||||
): AskValidationIssue[] {
|
): AskValidationIssue[] {
|
||||||
const collector = createIssueCollector();
|
const collector = createIssueCollector();
|
||||||
validateQuestions(params.questions, collector);
|
validateQuestions(params.questions, collector, options);
|
||||||
return collector.issues;
|
return collector.issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +55,14 @@ function normalizeOption(option: AskOption): AskOption {
|
|||||||
label: option.label.trim(),
|
label: option.label.trim(),
|
||||||
description: option.description?.trim(),
|
description: option.description?.trim(),
|
||||||
preview: option.preview?.trim(),
|
preview: option.preview?.trim(),
|
||||||
|
...(option.freeform ? { freeform: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateQuestions(
|
function validateQuestions(
|
||||||
questions: AskParams["questions"],
|
questions: AskParams["questions"],
|
||||||
collector: IssueCollector
|
collector: IssueCollector,
|
||||||
|
options: ValidationOptions
|
||||||
) {
|
) {
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
collector.add("questions", "At least one question is required");
|
collector.add("questions", "At least one question is required");
|
||||||
@@ -61,7 +71,7 @@ function validateQuestions(
|
|||||||
|
|
||||||
const questionIds = new Set<string>();
|
const questionIds = new Set<string>();
|
||||||
for (const [questionIndex, question] of questions.entries()) {
|
for (const [questionIndex, question] of questions.entries()) {
|
||||||
validateQuestion(question, questionIndex, questionIds, collector);
|
validateQuestion(question, questionIndex, questionIds, collector, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +79,8 @@ function validateQuestion(
|
|||||||
question: AskQuestionInput,
|
question: AskQuestionInput,
|
||||||
questionIndex: number,
|
questionIndex: number,
|
||||||
questionIds: Set<string>,
|
questionIds: Set<string>,
|
||||||
collector: IssueCollector
|
collector: IssueCollector,
|
||||||
|
options: ValidationOptions
|
||||||
) {
|
) {
|
||||||
const questionNumber = questionIndex + 1;
|
const questionNumber = questionIndex + 1;
|
||||||
const questionPath = `questions[${questionIndex}]`;
|
const questionPath = `questions[${questionIndex}]`;
|
||||||
@@ -114,6 +125,14 @@ function validateQuestion(
|
|||||||
`Question ${questionNumber}: at least one option is required`
|
`Question ${questionNumber}: at least one option is required`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
validateFreeformOptions(
|
||||||
|
question.options,
|
||||||
|
questionNumber,
|
||||||
|
collector,
|
||||||
|
`${questionPath}.options`,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
const optionValues = new Set<string>();
|
const optionValues = new Set<string>();
|
||||||
for (const [optionIndex, option] of question.options.entries()) {
|
for (const [optionIndex, option] of question.options.entries()) {
|
||||||
validateOption(
|
validateOption(
|
||||||
@@ -128,6 +147,32 @@ function validateQuestion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateFreeformOptions(
|
||||||
|
options: AskOption[],
|
||||||
|
questionNumber: number,
|
||||||
|
collector: IssueCollector,
|
||||||
|
path: string,
|
||||||
|
validationOptions: ValidationOptions
|
||||||
|
) {
|
||||||
|
const freeformCount = options.filter((option) => option.freeform).length;
|
||||||
|
if (freeformCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validationOptions.allowFreeform) {
|
||||||
|
collector.add(
|
||||||
|
path,
|
||||||
|
`Question ${questionNumber}: freeform options are only supported for /answer forms`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (freeformCount > 1 || options.length > 1) {
|
||||||
|
collector.add(
|
||||||
|
path,
|
||||||
|
`Question ${questionNumber}: freeform options must be the only option`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateOption(
|
function validateOption(
|
||||||
option: AskOption,
|
option: AskOption,
|
||||||
optionIndex: number,
|
optionIndex: number,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const CUSTOM_OPTION: AskDisplayOption = {
|
|||||||
isCustomOption: true,
|
isCustomOption: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FREEFORM_LABEL = "Type your answer:";
|
||||||
|
|
||||||
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
|
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
|
||||||
return state.questions[state.activeTabIndex];
|
return state.questions[state.activeTabIndex];
|
||||||
}
|
}
|
||||||
@@ -34,9 +36,30 @@ export function getRenderableOptions(
|
|||||||
if (!question) {
|
if (!question) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const freeformOption = getFreeformOption(question);
|
||||||
|
if (freeformOption) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...freeformOption,
|
||||||
|
label: DEFAULT_FREEFORM_LABEL,
|
||||||
|
isCustomOption: true,
|
||||||
|
isFreeformOnlyOption: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
return [...question.options, CUSTOM_OPTION];
|
return [...question.options, CUSTOM_OPTION];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFreeformOption(
|
||||||
|
question: AskQuestion
|
||||||
|
): AskDisplayOption | undefined {
|
||||||
|
if (question.options.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = question.options[0];
|
||||||
|
return option.freeform ? option : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentOption(
|
export function getCurrentOption(
|
||||||
state: AskState
|
state: AskState
|
||||||
): AskDisplayOption | undefined {
|
): AskDisplayOption | undefined {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AskQuestionType = "single" | "multi" | "preview";
|
|||||||
|
|
||||||
export interface AskOption {
|
export interface AskOption {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
freeform?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -147,6 +148,7 @@ export interface AskState {
|
|||||||
|
|
||||||
export interface AskDisplayOption extends AskOption {
|
export interface AskDisplayOption extends AskOption {
|
||||||
isCustomOption?: boolean;
|
isCustomOption?: boolean;
|
||||||
|
isFreeformOnlyOption?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AskAction =
|
export type AskAction =
|
||||||
|
|||||||
+23
-6
@@ -52,8 +52,16 @@ type Tui = CustomCallbackArgs[0];
|
|||||||
type Theme = CustomCallbackArgs[1];
|
type Theme = CustomCallbackArgs[1];
|
||||||
type Keybindings = CustomCallbackArgs[2];
|
type Keybindings = CustomCallbackArgs[2];
|
||||||
type Done = (result: AskResult) => void;
|
type Done = (result: AskResult) => void;
|
||||||
|
interface AskFlowOptions {
|
||||||
|
allowFreeform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type AskFlowParams = AskParams &
|
type AskFlowParams = AskParams &
|
||||||
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
|
Pick<ExtensionContext, "cwd"> & {
|
||||||
|
config: AskConfig;
|
||||||
|
ctx: ExtensionContext;
|
||||||
|
flowOptions: AskFlowOptions;
|
||||||
|
};
|
||||||
|
|
||||||
interface AskFlowController {
|
interface AskFlowController {
|
||||||
config: AskConfig;
|
config: AskConfig;
|
||||||
@@ -72,12 +80,19 @@ interface AskFlowController {
|
|||||||
|
|
||||||
export async function runAskFlow(
|
export async function runAskFlow(
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
params: AskParams
|
params: AskParams,
|
||||||
|
options: AskFlowOptions = {}
|
||||||
): Promise<AskResult> {
|
): Promise<AskResult> {
|
||||||
const store = getAskConfigStore();
|
const store = getAskConfigStore();
|
||||||
const config = await store.getConfig();
|
const config = await store.getConfig();
|
||||||
return ctx.ui.custom<AskResult>((...args) =>
|
return ctx.ui.custom<AskResult>((...args) =>
|
||||||
createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx })
|
createAskFlowController(args, {
|
||||||
|
...params,
|
||||||
|
config,
|
||||||
|
cwd: ctx.cwd,
|
||||||
|
ctx,
|
||||||
|
flowOptions: options,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +112,7 @@ function createAskFlowController(
|
|||||||
done,
|
done,
|
||||||
editor: createEditor(tui, theme, params.cwd),
|
editor: createEditor(tui, theme, params.cwd),
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
state: createInitialState(params),
|
state: createInitialState(params, params.flowOptions),
|
||||||
suppressAutoInputForSelection: false,
|
suppressAutoInputForSelection: false,
|
||||||
pendingReviewShortcutActionIndex: undefined,
|
pendingReviewShortcutActionIndex: undefined,
|
||||||
theme,
|
theme,
|
||||||
@@ -321,8 +336,10 @@ function submitEditor(controller: AskFlowController, value: string) {
|
|||||||
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
|
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
|
||||||
clearDismissNotice(controller);
|
clearDismissNotice(controller);
|
||||||
}
|
}
|
||||||
controller.state = maybeAutoSubmitState(nextState, controller.config);
|
controller.state = nextState;
|
||||||
controller.editor.setText("");
|
syncSelection(controller);
|
||||||
|
controller.state = maybeAutoSubmitState(controller.state, controller.config);
|
||||||
|
hydrateEditor(controller);
|
||||||
refresh(controller);
|
refresh(controller);
|
||||||
maybeFinish(controller);
|
maybeFinish(controller);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-19
@@ -76,7 +76,7 @@ function renderStandardOption(
|
|||||||
|
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
formatOptionLabel(row),
|
||||||
context.width,
|
context.width,
|
||||||
context.theme,
|
context.theme,
|
||||||
row.color,
|
row.color,
|
||||||
@@ -179,11 +179,16 @@ function renderOptionDetail(
|
|||||||
lines: string[],
|
lines: string[],
|
||||||
detail: OptionDetailModel | undefined,
|
detail: OptionDetailModel | undefined,
|
||||||
context: QuestionRenderContext,
|
context: QuestionRenderContext,
|
||||||
options: { suppressLeadingGap?: boolean } = {}
|
options: { indent?: string; suppressLeadingGap?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const indent = options.indent ?? " ";
|
||||||
|
const padding =
|
||||||
|
indent === " "
|
||||||
|
? UI_DIMENSIONS.editorContentPadding
|
||||||
|
: UI_DIMENSIONS.editorIndentedPadding;
|
||||||
if (detail.withGap && !options.suppressLeadingGap) {
|
if (detail.withGap && !options.suppressLeadingGap) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
@@ -193,8 +198,8 @@ function renderOptionDetail(
|
|||||||
editor: context.editor,
|
editor: context.editor,
|
||||||
width: context.width,
|
width: context.width,
|
||||||
theme: context.theme,
|
theme: context.theme,
|
||||||
indent: " ",
|
indent,
|
||||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
padding,
|
||||||
placeholder: detail.placeholder,
|
placeholder: detail.placeholder,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -205,7 +210,7 @@ function renderOptionDetail(
|
|||||||
note: detail.text,
|
note: detail.text,
|
||||||
width: context.width,
|
width: context.width,
|
||||||
theme: context.theme,
|
theme: context.theme,
|
||||||
indent: " ",
|
indent,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -215,8 +220,8 @@ function renderOptionDetail(
|
|||||||
context.width,
|
context.width,
|
||||||
context.theme,
|
context.theme,
|
||||||
"muted",
|
"muted",
|
||||||
" ",
|
indent,
|
||||||
" "
|
indent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,29 +249,29 @@ function renderEditorWithIndent(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOptionLabel(row: OptionRowModel): string {
|
||||||
|
return row.isFreeformOnly
|
||||||
|
? row.label
|
||||||
|
: `${row.index + 1}. ${row.prefix}${row.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderInteractiveCustomOption(
|
function renderInteractiveCustomOption(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
row: OptionRowModel,
|
row: OptionRowModel,
|
||||||
context: QuestionRenderContext
|
context: QuestionRenderContext
|
||||||
) {
|
) {
|
||||||
|
const indent = row.isFreeformOnly ? " " : row.pointer;
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
formatOptionLabel(row),
|
||||||
context.width,
|
context.width,
|
||||||
context.theme,
|
context.theme,
|
||||||
row.color,
|
row.color,
|
||||||
row.pointer,
|
indent,
|
||||||
" ".repeat(visibleWidth(row.pointer))
|
" ".repeat(visibleWidth(indent))
|
||||||
);
|
);
|
||||||
renderEditorWithIndent({
|
renderOptionDetail(lines, row.detail, context, {
|
||||||
lines,
|
indent: row.isFreeformOnly ? " " : undefined,
|
||||||
editor: context.editor,
|
|
||||||
width: context.width,
|
|
||||||
theme: context.theme,
|
|
||||||
indent: " ",
|
|
||||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
|
||||||
placeholder:
|
|
||||||
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface OptionRowModel {
|
|||||||
detail?: OptionDetailModel;
|
detail?: OptionDetailModel;
|
||||||
index: number;
|
index: number;
|
||||||
isCustom: boolean;
|
isCustom: boolean;
|
||||||
|
isFreeformOnly: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
pointer: string;
|
pointer: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -89,6 +90,7 @@ function buildOptionRowModel(
|
|||||||
const answered = option.isCustomOption
|
const answered = option.isCustomOption
|
||||||
? !!answer?.customText
|
? !!answer?.customText
|
||||||
: isOptionSelected(answer, option.value);
|
: isOptionSelected(answer, option.value);
|
||||||
|
const pointer = getOptionPointer(option, selected);
|
||||||
return {
|
return {
|
||||||
color: getOptionColor(answered, selected),
|
color: getOptionColor(answered, selected),
|
||||||
description: option.description,
|
description: option.description,
|
||||||
@@ -98,13 +100,21 @@ function buildOptionRowModel(
|
|||||||
: buildOptionDetailModel(context, option, selected),
|
: buildOptionDetailModel(context, option, selected),
|
||||||
index,
|
index,
|
||||||
isCustom: !!option.isCustomOption,
|
isCustom: !!option.isCustomOption,
|
||||||
|
isFreeformOnly: !!option.isFreeformOnlyOption,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
pointer: selected ? "❯ " : " ",
|
pointer,
|
||||||
prefix: getOptionPrefix(question.type, option, answered),
|
prefix: getOptionPrefix(question.type, option, answered),
|
||||||
selected,
|
selected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOptionPointer(option: AskDisplayOption, selected: boolean): string {
|
||||||
|
if (option.isFreeformOnlyOption) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return selected ? "❯ " : " ";
|
||||||
|
}
|
||||||
|
|
||||||
function buildOptionDetailModel(
|
function buildOptionDetailModel(
|
||||||
context: QuestionRenderContext,
|
context: QuestionRenderContext,
|
||||||
option: AskDisplayOption | undefined,
|
option: AskDisplayOption | undefined,
|
||||||
@@ -139,11 +149,15 @@ function buildOptionDetailModel(
|
|||||||
return {
|
return {
|
||||||
kind: "editor",
|
kind: "editor",
|
||||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||||
withGap: false,
|
withGap: !!option.isFreeformOnlyOption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (customText) {
|
if (customText) {
|
||||||
return { kind: "custom-text", text: customText, withGap: false };
|
return {
|
||||||
|
kind: "custom-text",
|
||||||
|
text: customText,
|
||||||
|
withGap: !!option.isFreeformOnlyOption,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (note) {
|
if (note) {
|
||||||
return { kind: "saved-note", text: note, withGap: false };
|
return { kind: "saved-note", text: note, withGap: false };
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
collectExtractionBusinessIssues,
|
||||||
|
repairExtractionParams,
|
||||||
|
selectExtractionModel,
|
||||||
|
} from "../src/answer-extraction.ts";
|
||||||
|
|
||||||
|
function model(provider: string, id: string) {
|
||||||
|
return { provider, id } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("selectExtractionModel uses first configured model with auth", async () => {
|
||||||
|
const first = model("missing", "a");
|
||||||
|
const second = model("ok", "b");
|
||||||
|
const result = await selectExtractionModel(
|
||||||
|
{
|
||||||
|
model: model("fallback", "c"),
|
||||||
|
modelRegistry: {
|
||||||
|
find(provider: string, _id: string) {
|
||||||
|
if (provider === "missing") {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
if (provider === "ok") {
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getApiKeyAndHeaders(candidate: { provider: string }) {
|
||||||
|
if (candidate.provider === "ok") {
|
||||||
|
return Promise.resolve({ ok: true, apiKey: "key" });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, error: "no" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
[
|
||||||
|
{ provider: "missing", id: "a" },
|
||||||
|
{ provider: "ok", id: "b" },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
model: second,
|
||||||
|
auth: { ok: true, apiKey: "key" },
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extraction business rules flag option spam", () => {
|
||||||
|
const issues = collectExtractionBusinessIssues({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q",
|
||||||
|
prompt: "Pick one",
|
||||||
|
options: [
|
||||||
|
{ value: "a", label: "A" },
|
||||||
|
{ value: "b", label: "B" },
|
||||||
|
{ value: "c", label: "C" },
|
||||||
|
{ value: "d", label: "D" },
|
||||||
|
{ value: "e", label: "E" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(issues, ["questions[0].options has 5 items; max is 4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairExtractionParams caps options while preserving other", () => {
|
||||||
|
const repaired = repairExtractionParams({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q",
|
||||||
|
prompt: "Pick one",
|
||||||
|
options: [
|
||||||
|
{ value: "a", label: "A" },
|
||||||
|
{ value: "b", label: "B" },
|
||||||
|
{ value: "c", label: "C" },
|
||||||
|
{ value: "d", label: "D" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
repaired.questions[0]?.options.map((option) => option.value),
|
||||||
|
["a", "b", "c", "other"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repairExtractionParams drops generic conversational prompts", () => {
|
||||||
|
const repaired = repairExtractionParams({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "generic",
|
||||||
|
prompt: "How can I help you today?",
|
||||||
|
options: [{ value: "help", label: "Help me" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(repaired.questions, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectExtractionModel validates fallback model auth", async () => {
|
||||||
|
const fallback = model("fallback", "c");
|
||||||
|
const result = await selectExtractionModel(
|
||||||
|
{
|
||||||
|
model: fallback,
|
||||||
|
modelRegistry: {
|
||||||
|
find() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
getApiKeyAndHeaders() {
|
||||||
|
return Promise.resolve({ ok: false, error: "no auth" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
error: "No auth for fallback chat model: fallback/c.",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { findLatestPayloadInCurrentBranch } from "../src/ask-payload-store.ts";
|
||||||
|
import type { AskParams } from "../src/types.ts";
|
||||||
|
|
||||||
|
const params: AskParams = {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "goal",
|
||||||
|
prompt: "Goal?",
|
||||||
|
options: [{ value: "a", label: "A" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function custom(data: unknown) {
|
||||||
|
return { type: "custom", customType: "ask:payload", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctx(branch: unknown[]) {
|
||||||
|
return { sessionManager: { getBranch: () => branch } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("branch payload lookup returns the latest valid matching source", () => {
|
||||||
|
const older = {
|
||||||
|
version: 1,
|
||||||
|
source: "tool",
|
||||||
|
params,
|
||||||
|
timestamp: 1,
|
||||||
|
};
|
||||||
|
const newer = {
|
||||||
|
version: 1,
|
||||||
|
source: "tool",
|
||||||
|
params: { ...params, title: "Newer" },
|
||||||
|
timestamp: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = findLatestPayloadInCurrentBranch(
|
||||||
|
ctx([custom(older), custom(newer)]),
|
||||||
|
"tool"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.data, newer);
|
||||||
|
assert.equal(result.invalidMatchFound, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branch payload lookup allows freeform only for answer extraction payloads", () => {
|
||||||
|
const freeformParams: AskParams = {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
prompt: "Which language?",
|
||||||
|
options: [{ value: "freeform", label: "Type answer", freeform: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const answerPayload = {
|
||||||
|
version: 1,
|
||||||
|
source: "answer-extraction",
|
||||||
|
params: freeformParams,
|
||||||
|
timestamp: 1,
|
||||||
|
};
|
||||||
|
const toolPayload = {
|
||||||
|
version: 1,
|
||||||
|
source: "tool",
|
||||||
|
params: freeformParams,
|
||||||
|
timestamp: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
findLatestPayloadInCurrentBranch(
|
||||||
|
ctx([custom(answerPayload)]),
|
||||||
|
"answer-extraction"
|
||||||
|
).data,
|
||||||
|
answerPayload
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
findLatestPayloadInCurrentBranch(ctx([custom(toolPayload)]), "tool").data,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branch payload lookup ignores invalid stored payloads", () => {
|
||||||
|
const result = findLatestPayloadInCurrentBranch(
|
||||||
|
ctx([
|
||||||
|
custom({ version: 1, source: "tool", params: { questions: [] } }),
|
||||||
|
custom({ version: 1, source: "answer-extraction", params }),
|
||||||
|
]),
|
||||||
|
"tool"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.data, undefined);
|
||||||
|
assert.equal(result.invalidMatchFound, true);
|
||||||
|
});
|
||||||
+40
-16
@@ -24,19 +24,26 @@ const noop = () => {
|
|||||||
|
|
||||||
function registerMockTool() {
|
function registerMockTool() {
|
||||||
const tools: Record<string, unknown>[] = [];
|
const tools: Record<string, unknown>[] = [];
|
||||||
|
const entries: Array<{ customType: string; data: unknown }> = [];
|
||||||
registerAskTool({
|
registerAskTool({
|
||||||
|
appendEntry(customType: string, data: unknown) {
|
||||||
|
entries.push({ customType, data });
|
||||||
|
},
|
||||||
registerTool(tool: unknown) {
|
registerTool(tool: unknown) {
|
||||||
tools.push(tool as Record<string, unknown>);
|
tools.push(tool as Record<string, unknown>);
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
return tools[0] as {
|
return {
|
||||||
execute: (...args: any[]) => Promise<any>;
|
entries,
|
||||||
renderCall: (args: unknown, theme: any) => { text: string };
|
tool: tools[0] as {
|
||||||
renderResult: (
|
execute: (...args: any[]) => Promise<any>;
|
||||||
result: any,
|
renderCall: (args: unknown, theme: any) => { text: string };
|
||||||
options: unknown,
|
renderResult: (
|
||||||
theme: any
|
result: any,
|
||||||
) => { text: string };
|
options: unknown,
|
||||||
|
theme: any
|
||||||
|
) => { text: string };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +68,25 @@ function sampleParams(): AskParams {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("ask tool stores valid payloads as soon as they are called", async () => {
|
||||||
|
const { entries, tool } = registerMockTool();
|
||||||
|
const params = sampleParams();
|
||||||
|
|
||||||
|
await tool.execute("call-1", params, undefined, noop, makeCtx(false));
|
||||||
|
|
||||||
|
assert.equal(entries.length, 1);
|
||||||
|
assert.equal(entries[0].customType, "ask:payload");
|
||||||
|
assert.deepEqual(entries[0].data, {
|
||||||
|
version: 1,
|
||||||
|
source: "tool",
|
||||||
|
params,
|
||||||
|
sourceEntryId: "call-1",
|
||||||
|
timestamp: (entries[0].data as { timestamp: number }).timestamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("ask tool returns pending questions in non-interactive mode", async () => {
|
test("ask tool returns pending questions in non-interactive mode", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -90,7 +114,7 @@ test("ask tool returns pending questions in non-interactive mode", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool includes custom answer fallback for preview questions", async () => {
|
test("ask tool includes custom answer fallback for preview questions", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -115,7 +139,7 @@ test("ask tool includes custom answer fallback for preview questions", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool rejects invalid payloads before UI opens with structured issues", async () => {
|
test("ask tool rejects invalid payloads before UI opens with structured issues", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -155,7 +179,7 @@ test("ask tool rejects invalid payloads before UI opens with structured issues",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool reports missing option values with structured issues", async () => {
|
test("ask tool reports missing option values with structured issues", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -188,7 +212,7 @@ test("ask tool reports missing option values with structured issues", async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool reports empty questions with structured issues", async () => {
|
test("ask tool reports empty questions with structured issues", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -215,7 +239,7 @@ test("ask tool reports empty questions with structured issues", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool reports invalid question types with structured issues", async () => {
|
test("ask tool reports invalid question types with structured issues", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -250,7 +274,7 @@ test("ask tool reports invalid question types with structured issues", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool reports preview validation with structured issues", async () => {
|
test("ask tool reports preview validation with structured issues", async () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
|
|
||||||
const result = await tool.execute(
|
const result = await tool.execute(
|
||||||
"call-1",
|
"call-1",
|
||||||
@@ -285,7 +309,7 @@ test("ask tool reports preview validation with structured issues", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool transcript renderers summarize call and cancelled result", () => {
|
test("ask tool transcript renderers summarize call and cancelled result", () => {
|
||||||
const tool = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
const theme = {
|
const theme = {
|
||||||
bold: (text: string) => text,
|
bold: (text: string) => text,
|
||||||
fg: (_token: string, text: string) => text,
|
fg: (_token: string, text: string) => text,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ test("config store writes defaults when file is missing", async () => {
|
|||||||
|
|
||||||
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
||||||
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), {
|
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
answer: DEFAULT_ASK_CONFIG.answer,
|
||||||
behaviour: DEFAULT_ASK_CONFIG.behaviour,
|
behaviour: DEFAULT_ASK_CONFIG.behaviour,
|
||||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||||
});
|
});
|
||||||
@@ -47,7 +48,8 @@ test("config store writes full normalized config on save", async () => {
|
|||||||
|
|
||||||
const content = await readFile(path, "utf-8");
|
const content = await readFile(path, "utf-8");
|
||||||
assert.deepEqual(JSON.parse(content), {
|
assert.deepEqual(JSON.parse(content), {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
answer: DEFAULT_ASK_CONFIG.answer,
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||||
confirmDismissWhenDirty: true,
|
confirmDismissWhenDirty: true,
|
||||||
@@ -143,7 +145,8 @@ test("config store migrates the legacy root config path into extensions", async
|
|||||||
assert.equal(result.config.behaviour.showFooterHints, false);
|
assert.equal(result.config.behaviour.showFooterHints, false);
|
||||||
await assert.rejects(readFile(legacyPath, "utf-8"));
|
await assert.rejects(readFile(legacyPath, "utf-8"));
|
||||||
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), {
|
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
answer: DEFAULT_ASK_CONFIG.answer,
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||||
confirmDismissWhenDirty: true,
|
confirmDismissWhenDirty: true,
|
||||||
|
|||||||
@@ -66,6 +66,91 @@ test("submitEditorDraft advances submitted custom answers", () => {
|
|||||||
assert.equal(next.view.kind, "submit");
|
assert.equal(next.view.kind, "submit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("submitted answer can sync directly into next freeform input", () => {
|
||||||
|
let state = syncStateToSelection(
|
||||||
|
createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "First?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2",
|
||||||
|
prompt: "Second?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
state = submitEditorDraft(state, "first answer");
|
||||||
|
state = syncStateToSelection(state);
|
||||||
|
|
||||||
|
assert.equal(state.activeTabIndex, 1);
|
||||||
|
assert.deepEqual(state.view, { kind: "input", questionId: "q2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("syncStateToSelection does not reopen answered freeform input", () => {
|
||||||
|
let state = syncStateToSelection(
|
||||||
|
createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "First?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
state = submitEditorDraft(state, "already answered");
|
||||||
|
state = syncStateToSelection({
|
||||||
|
...state,
|
||||||
|
activeTabIndex: 0,
|
||||||
|
view: { kind: "navigate" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.view.kind, "navigate");
|
||||||
|
assert.equal(state.answers.q1.customText, "already answered");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab navigation can sync from review back into freeform input", () => {
|
||||||
|
let state = createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "First?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
);
|
||||||
|
state = { ...state, activeTabIndex: 1, view: { kind: "submit" } };
|
||||||
|
state = syncStateToSelection({
|
||||||
|
...state,
|
||||||
|
activeTabIndex: 0,
|
||||||
|
view: { kind: "navigate" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(state.view, { kind: "input", questionId: "q1" });
|
||||||
|
});
|
||||||
|
|
||||||
test("submitEditorDraft saves notes without completing the ask flow", () => {
|
test("submitEditorDraft saves notes without completing the ask flow", () => {
|
||||||
const state = enterQuestionNoteMode(
|
const state = enterQuestionNoteMode(
|
||||||
createInitialState({
|
createInitialState({
|
||||||
|
|||||||
@@ -189,6 +189,47 @@ test("selected multiline custom option renders full editor block", () => {
|
|||||||
assert(!lines.some((line) => line.includes("first line second line")));
|
assert(!lines.some((line) => line.includes("first line second line")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("freeform-only question renders label without numbering or pointer and separates input", () => {
|
||||||
|
const state = createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Type one",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
renderQuestionScreen({
|
||||||
|
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||||||
|
lines,
|
||||||
|
options: getRenderableOptions(state.questions[0]),
|
||||||
|
question: state.questions[0],
|
||||||
|
state: { ...state, view: { kind: "input", questionId: "q1" } },
|
||||||
|
theme: mockTheme(),
|
||||||
|
width: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelIndex = lines.findIndex((line) =>
|
||||||
|
line.includes("Type your answer:")
|
||||||
|
);
|
||||||
|
const inputIndex = lines.findIndex((line) => line.includes("Type answer..."));
|
||||||
|
|
||||||
|
assert.notEqual(labelIndex, -1);
|
||||||
|
assert.equal(inputIndex, labelIndex + 2);
|
||||||
|
assert(lines[labelIndex]?.startsWith(" "));
|
||||||
|
assert(lines[inputIndex]?.startsWith(" "));
|
||||||
|
assert(!lines[inputIndex]?.startsWith(" "));
|
||||||
|
assert(!lines.some((line) => line.includes("1. Type your answer:")));
|
||||||
|
assert(!lines.some((line) => line.includes("❯")));
|
||||||
|
});
|
||||||
|
|
||||||
test("preview questions also show the custom answer option", () => {
|
test("preview questions also show the custom answer option", () => {
|
||||||
const state = createInitialState({
|
const state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
|
||||||
import type { AskConfig } from "../src/config/schema.ts";
|
import type { AskConfig } from "../src/config/schema.ts";
|
||||||
import { AskSettingsList } from "../src/ui/settings-list.ts";
|
import { AskSettingsList } from "../src/ui/settings-list.ts";
|
||||||
|
|
||||||
const savedConfig: AskConfig = {
|
const savedConfig: AskConfig = {
|
||||||
|
answer: {
|
||||||
|
...DEFAULT_ASK_CONFIG.answer,
|
||||||
|
},
|
||||||
behaviour: {
|
behaviour: {
|
||||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||||
confirmDismissWhenDirty: true,
|
confirmDismissWhenDirty: true,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const DUPLICATE_QUESTION_ID_RE = /duplicate question id "scope"/;
|
|||||||
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/;
|
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/;
|
||||||
const PREVIEW_REQUIRED_RE =
|
const PREVIEW_REQUIRED_RE =
|
||||||
/preview questions require preview text for every option/;
|
/preview questions require preview text for every option/;
|
||||||
|
const FREEFORM_UNSUPPORTED_RE =
|
||||||
|
/freeform options are only supported for \/answer forms/;
|
||||||
|
const FREEFORM_MIXED_RE = /freeform options must be the only option/;
|
||||||
|
|
||||||
import { toAskResult } from "../src/state/result.ts";
|
import { toAskResult } from "../src/state/result.ts";
|
||||||
import { getRenderableOptions } from "../src/state/selectors.ts";
|
import { getRenderableOptions } from "../src/state/selectors.ts";
|
||||||
@@ -76,6 +79,68 @@ test("normalize defaults via initial state", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("answer freeform questions render only the custom input option", () => {
|
||||||
|
const state = createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
prompt: "Which language do you want to learn?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = getRenderableOptions(state.questions[0]);
|
||||||
|
assert.equal(options.length, 1);
|
||||||
|
assert.equal(options[0]?.label, "Type your answer:");
|
||||||
|
assert.equal(options[0]?.isCustomOption, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freeform questions are rejected unless explicitly allowed", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createInitialState({
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
prompt: "Which language do you want to learn?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
FREEFORM_UNSUPPORTED_RE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freeform questions cannot mix normal options", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createInitialState(
|
||||||
|
{
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
prompt: "Which language do you want to learn?",
|
||||||
|
options: [
|
||||||
|
{ value: "freeform", label: "Type answer", freeform: true },
|
||||||
|
{ value: "french", label: "French" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ allowFreeform: true }
|
||||||
|
),
|
||||||
|
FREEFORM_MIXED_RE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("preview questions keep their type and option previews", () => {
|
test("preview questions keep their type and option previews", () => {
|
||||||
const state = createInitialState({
|
const state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
|
|||||||
Reference in New Issue
Block a user