This commit is contained in:
@@ -14,7 +14,8 @@ 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, ask payload capture
|
- `src/ask-tool.ts` — tool registration, mode routing, transcript rendering, ask payload capture
|
||||||
|
- `src/rpc-flow.ts` — standard-dialog fallback for RPC clients
|
||||||
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
|
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
|
||||||
- `src/answer-extraction.ts` — configured, session-scope-aware extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback
|
- `src/answer-extraction.ts` — configured, session-scope-aware extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback
|
||||||
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
|
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
|
||||||
|
|||||||
+4
-2
@@ -268,7 +268,9 @@ Dirty dismiss:
|
|||||||
|
|
||||||
## Non-TUI and non-interactive modes
|
## Non-TUI and non-interactive modes
|
||||||
|
|
||||||
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening custom UI.
|
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. RPC mode falls back to Pi's standard `select` and `input` dialogs so RPC clients can bridge questions into their own interface. Questions are presented sequentially; multi-select questions use repeated toggle dialogs followed by `Done`. The RPC fallback collects option selections and custom text, but does not expose preview panes, notes, or elaboration actions.
|
||||||
|
|
||||||
|
Print, JSON, and other non-interactive modes return a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening UI.
|
||||||
|
|
||||||
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, and it restricts question `type` to `single`, `multi`, or `preview`, so malformed structural fields fail before execution. The tool still validates trimmed text, uniqueness, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
|
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, and it restricts question `type` to `single`, `multi`, or `preview`, so malformed structural fields fail before execution. The tool still validates trimmed text, uniqueness, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
|
||||||
|
|
||||||
@@ -280,7 +282,7 @@ When enabled, pi-ask emits one best-effort external notification per ask session
|
|||||||
|
|
||||||
## Remote inter-extension events
|
## Remote inter-extension events
|
||||||
|
|
||||||
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. RPC or headless integrations should use a trusted in-process bridge extension that consumes these events rather than expecting the TUI-only custom surface to open.
|
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. Integrations that need the complete rich-flow semantics should use a trusted in-process bridge extension that consumes these events; the built-in RPC fallback intentionally exposes only standard dialog semantics.
|
||||||
|
|
||||||
Channels:
|
Channels:
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./ask-tool-helpers.ts";
|
} from "./ask-tool-helpers.ts";
|
||||||
import { getAskConfigStore } from "./config/store.ts";
|
import { getAskConfigStore } from "./config/store.ts";
|
||||||
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
||||||
|
import { runRpcAskFlow } from "./rpc-flow.ts";
|
||||||
import { AskParamsSchema } from "./schema.ts";
|
import { AskParamsSchema } from "./schema.ts";
|
||||||
import { prepareAskParams } from "./state/normalize.ts";
|
import { prepareAskParams } from "./state/normalize.ts";
|
||||||
import type { AskParams } from "./types.ts";
|
import type { AskParams } from "./types.ts";
|
||||||
@@ -69,6 +70,9 @@ async function executeAskTool(
|
|||||||
source: "tool",
|
source: "tool",
|
||||||
sourceEntryId: toolCallId,
|
sourceEntryId: toolCallId,
|
||||||
});
|
});
|
||||||
|
if (ctx.mode === "rpc") {
|
||||||
|
return successfulResponse(await runRpcAskFlow(ctx.ui, validation.state));
|
||||||
|
}
|
||||||
if (ctx.mode !== "tui") {
|
if (ctx.mode !== "tui") {
|
||||||
return nonInteractiveResponse(validation.state);
|
return nonInteractiveResponse(validation.state);
|
||||||
}
|
}
|
||||||
|
|||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type {
|
||||||
|
AskQuestion,
|
||||||
|
AskResult,
|
||||||
|
AskResultAnswer,
|
||||||
|
AskState,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
type DialogUi = Pick<ExtensionContext["ui"], "input" | "select">;
|
||||||
|
|
||||||
|
const CUSTOM_ANSWER_LABEL = "Type your own";
|
||||||
|
const DONE_LABEL = "Done";
|
||||||
|
|
||||||
|
export async function runRpcAskFlow(
|
||||||
|
ui: DialogUi,
|
||||||
|
state: AskState
|
||||||
|
): Promise<AskResult> {
|
||||||
|
const answers: AskResult["answers"] = {};
|
||||||
|
|
||||||
|
for (const question of state.questions) {
|
||||||
|
const answer = await askRpcQuestion(ui, question);
|
||||||
|
if (!answer) {
|
||||||
|
return createRpcResult(state, answers, true);
|
||||||
|
}
|
||||||
|
answers[question.id] = answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRpcResult(state, answers, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askRpcQuestion(
|
||||||
|
ui: DialogUi,
|
||||||
|
question: AskQuestion
|
||||||
|
): Promise<AskResultAnswer | undefined> {
|
||||||
|
if (question.type === "multi") {
|
||||||
|
return askRpcMultiQuestion(ui, question);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionLabels = question.options.map((option, index) =>
|
||||||
|
formatOptionLabel(index, option.label)
|
||||||
|
);
|
||||||
|
const customLabel = formatOptionLabel(
|
||||||
|
question.options.length,
|
||||||
|
CUSTOM_ANSWER_LABEL
|
||||||
|
);
|
||||||
|
const selectedLabel = await ui.select(questionTitle(question), [
|
||||||
|
...optionLabels,
|
||||||
|
customLabel,
|
||||||
|
]);
|
||||||
|
if (selectedLabel === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedLabel === customLabel) {
|
||||||
|
return askRpcCustomAnswer(ui, question, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = optionLabels.indexOf(selectedLabel);
|
||||||
|
const option = question.options[selectedIndex];
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return serializeSelections(question, [selectedIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askRpcMultiQuestion(
|
||||||
|
ui: DialogUi,
|
||||||
|
question: AskQuestion
|
||||||
|
): Promise<AskResultAnswer | undefined> {
|
||||||
|
const selectedIndices = new Set<number>();
|
||||||
|
let customText: string | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const step = await askRpcMultiStep(
|
||||||
|
ui,
|
||||||
|
question,
|
||||||
|
selectedIndices,
|
||||||
|
customText
|
||||||
|
);
|
||||||
|
if (step.kind === "cancel") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step.kind === "done") {
|
||||||
|
return step.answer;
|
||||||
|
}
|
||||||
|
customText = step.customText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiStep =
|
||||||
|
| { kind: "cancel" }
|
||||||
|
| { kind: "continue"; customText?: string }
|
||||||
|
| { kind: "done"; answer: AskResultAnswer };
|
||||||
|
|
||||||
|
async function askRpcMultiStep(
|
||||||
|
ui: DialogUi,
|
||||||
|
question: AskQuestion,
|
||||||
|
selectedIndices: Set<number>,
|
||||||
|
customText: string | undefined
|
||||||
|
): Promise<MultiStep> {
|
||||||
|
const optionLabels = question.options.map((option, index) =>
|
||||||
|
formatOptionLabel(
|
||||||
|
index,
|
||||||
|
`[${selectedIndices.has(index) ? "x" : " "}] ${option.label}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const doneLabel = formatOptionLabel(question.options.length, DONE_LABEL);
|
||||||
|
const customLabel = formatOptionLabel(
|
||||||
|
question.options.length + 1,
|
||||||
|
CUSTOM_ANSWER_LABEL
|
||||||
|
);
|
||||||
|
const selectedLabel = await ui.select(questionTitle(question), [
|
||||||
|
...optionLabels,
|
||||||
|
doneLabel,
|
||||||
|
customLabel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (selectedLabel === undefined) {
|
||||||
|
return { kind: "cancel" };
|
||||||
|
}
|
||||||
|
if (selectedLabel === doneLabel) {
|
||||||
|
return {
|
||||||
|
kind: "done",
|
||||||
|
answer: serializeSelections(
|
||||||
|
question,
|
||||||
|
[...selectedIndices].sort((left, right) => left - right),
|
||||||
|
customText
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedLabel === customLabel) {
|
||||||
|
const customAnswer = await ui.input(
|
||||||
|
questionTitle(question),
|
||||||
|
"Type your answer"
|
||||||
|
);
|
||||||
|
return customAnswer === undefined
|
||||||
|
? { kind: "cancel" }
|
||||||
|
: { kind: "continue", customText: customAnswer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = optionLabels.indexOf(selectedLabel);
|
||||||
|
if (selectedIndex < 0) {
|
||||||
|
return { kind: "cancel" };
|
||||||
|
}
|
||||||
|
toggleSelectedIndex(selectedIndices, selectedIndex);
|
||||||
|
return { kind: "continue", customText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectedIndex(
|
||||||
|
selectedIndices: Set<number>,
|
||||||
|
selectedIndex: number
|
||||||
|
): void {
|
||||||
|
if (selectedIndices.has(selectedIndex)) {
|
||||||
|
selectedIndices.delete(selectedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedIndices.add(selectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askRpcCustomAnswer(
|
||||||
|
ui: DialogUi,
|
||||||
|
question: AskQuestion,
|
||||||
|
selectedIndices: number[]
|
||||||
|
): Promise<AskResultAnswer | undefined> {
|
||||||
|
const customText = await ui.input(
|
||||||
|
questionTitle(question),
|
||||||
|
"Type your answer"
|
||||||
|
);
|
||||||
|
if (customText === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return serializeSelections(question, selectedIndices, customText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSelections(
|
||||||
|
question: AskQuestion,
|
||||||
|
selectedIndices: number[],
|
||||||
|
customText?: string
|
||||||
|
): AskResultAnswer {
|
||||||
|
const selectedOptions = selectedIndices.map(
|
||||||
|
(index) => question.options[index]
|
||||||
|
);
|
||||||
|
const answer: AskResultAnswer = {
|
||||||
|
indices: selectedIndices,
|
||||||
|
labels: selectedOptions.map((option) => option.label),
|
||||||
|
values: selectedOptions.map((option) => option.value),
|
||||||
|
};
|
||||||
|
if (customText !== undefined) {
|
||||||
|
answer.customText = customText;
|
||||||
|
}
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRpcResult(
|
||||||
|
state: AskState,
|
||||||
|
answers: AskResult["answers"],
|
||||||
|
cancelled: boolean
|
||||||
|
): AskResult {
|
||||||
|
return {
|
||||||
|
title: state.title,
|
||||||
|
cancelled,
|
||||||
|
mode: "submit",
|
||||||
|
questions: state.questions.map((question) => {
|
||||||
|
const summary: AskResult["questions"][number] = {
|
||||||
|
id: question.id,
|
||||||
|
label: question.label,
|
||||||
|
prompt: question.prompt,
|
||||||
|
type: question.requestedType ?? question.type,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
question.presentedType &&
|
||||||
|
question.presentedType !== question.requestedType
|
||||||
|
) {
|
||||||
|
summary.presentedType = question.presentedType;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}),
|
||||||
|
answers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionTitle(question: AskQuestion): string {
|
||||||
|
return `${question.label}: ${question.prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptionLabel(index: number, label: string): string {
|
||||||
|
return `${index + 1}. ${label}`;
|
||||||
|
}
|
||||||
+109
-11
@@ -56,6 +56,31 @@ function makeCtx(hasUi: boolean, mode = hasUi ? "tui" : "print"): unknown {
|
|||||||
return { [HAS_UI]: hasUi, mode };
|
return { [HAS_UI]: hasUi, mode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeRpcCtx(responses: Array<string | undefined>) {
|
||||||
|
const requests: Array<{
|
||||||
|
title: string;
|
||||||
|
options?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}> = [];
|
||||||
|
return {
|
||||||
|
ctx: {
|
||||||
|
[HAS_UI]: true,
|
||||||
|
mode: "rpc",
|
||||||
|
ui: {
|
||||||
|
select(title: string, options: string[]) {
|
||||||
|
requests.push({ title, options });
|
||||||
|
return Promise.resolve(responses.shift());
|
||||||
|
},
|
||||||
|
input(title: string, placeholder?: string) {
|
||||||
|
requests.push({ title, placeholder });
|
||||||
|
return Promise.resolve(responses.shift());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sampleParams(): AskParams {
|
function sampleParams(): AskParams {
|
||||||
return {
|
return {
|
||||||
title: "Clarify next step",
|
title: "Clarify next step",
|
||||||
@@ -155,23 +180,96 @@ test("ask tool returns pending questions in non-interactive mode", async () => {
|
|||||||
assert.match(result.content[0].text, CUSTOM_OPTION_RE);
|
assert.match(result.content[0].text, CUSTOM_OPTION_RE);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool does not open custom UI outside TUI mode", async () => {
|
test("ask tool uses standard dialogs in RPC mode", async () => {
|
||||||
const { tool } = registerMockTool();
|
const { tool } = registerMockTool();
|
||||||
let customOpened = false;
|
const { ctx, requests } = makeRpcCtx(["1. Speed"]);
|
||||||
|
|
||||||
const result = await tool.execute("call-1", sampleParams(), undefined, noop, {
|
const result = await tool.execute(
|
||||||
[HAS_UI]: true,
|
"call-1",
|
||||||
mode: "rpc",
|
sampleParams(),
|
||||||
ui: {
|
undefined,
|
||||||
custom() {
|
noop,
|
||||||
customOpened = true;
|
ctx
|
||||||
},
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
title: "Goal: What should I optimize for?",
|
||||||
|
options: ["1. Speed", "2. Safety", "3. Type your own"],
|
||||||
},
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(result.details.cancelled, false);
|
||||||
|
assert.deepEqual(result.details.answers.goal, {
|
||||||
|
indices: [0],
|
||||||
|
labels: ["Speed"],
|
||||||
|
values: ["speed"],
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ask tool collects a custom answer through an RPC input dialog", async () => {
|
||||||
|
const { tool } = registerMockTool();
|
||||||
|
const { ctx, requests } = makeRpcCtx(["3. Type your own", "Balance both"]);
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
"call-1",
|
||||||
|
sampleParams(),
|
||||||
|
undefined,
|
||||||
|
noop,
|
||||||
|
ctx
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(requests[1], {
|
||||||
|
title: "Goal: What should I optimize for?",
|
||||||
|
placeholder: "Type your answer",
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.details.answers.goal, {
|
||||||
|
indices: [],
|
||||||
|
labels: [],
|
||||||
|
values: [],
|
||||||
|
customText: "Balance both",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ask tool collects multiple selections through RPC dialogs", async () => {
|
||||||
|
const { tool } = registerMockTool();
|
||||||
|
const params = sampleParams();
|
||||||
|
params.questions[0].type = "multi";
|
||||||
|
const { ctx, requests } = makeRpcCtx([
|
||||||
|
"1. [ ] Speed",
|
||||||
|
"2. [ ] Safety",
|
||||||
|
"3. Done",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await tool.execute("call-1", params, undefined, noop, ctx);
|
||||||
|
|
||||||
|
assert.equal(requests.length, 3);
|
||||||
|
assert.deepEqual(requests[1]?.options, [
|
||||||
|
"1. [x] Speed",
|
||||||
|
"2. [ ] Safety",
|
||||||
|
"3. Done",
|
||||||
|
"4. Type your own",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(result.details.answers.goal, {
|
||||||
|
indices: [0, 1],
|
||||||
|
labels: ["Speed", "Safety"],
|
||||||
|
values: ["speed", "safety"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ask tool returns a cancelled RPC result when a dialog is dismissed", async () => {
|
||||||
|
const { tool } = registerMockTool();
|
||||||
|
const { ctx } = makeRpcCtx([undefined]);
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
"call-1",
|
||||||
|
sampleParams(),
|
||||||
|
undefined,
|
||||||
|
noop,
|
||||||
|
ctx
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(customOpened, false);
|
|
||||||
assert.equal(result.details.cancelled, true);
|
assert.equal(result.details.cancelled, true);
|
||||||
assert.match(result.content[0].text, NON_INTERACTIVE_MESSAGE_RE);
|
assert.deepEqual(result.details.answers, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ask tool includes custom answer fallback for preview questions", async () => {
|
test("ask tool includes custom answer fallback for preview questions", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user