1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* ask_user - let the agent ask the user a question without blocking.
*
* The tool returns immediately with a pending id, and the question opens as
* a dialog in parallel with the agent's turn, so the model can keep working
* while it waits. When the user answers, the answer is sent as a steering
* user message tagged with the id and the question — delivered after the
* current assistant turn, or as a fresh turn if the agent went idle first.
*
* Uses the built-in ctx.ui dialogs rather than a custom component, so it
* also works over RPC (headless modes get an error result instead).
*/
import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
interface AskUserDetails {
id: string;
question: string;
options: string[];
}
const OTHER = "Other…";
// One dialog at a time: a second ctx.ui dialog disposes the first without
// resolving its promise, so concurrent questions would hang.
let dialogChain: Promise<void> = Promise.resolve();
async function showDialog(
ctx: ExtensionToolContext,
question: string,
options: string[],
): Promise<string | null> {
if (options.length === 0) {
return (await ctx.ui.input(question, "Your answer…")) ?? null;
}
const choice = await ctx.ui.select(question, [...options, OTHER]);
if (choice === undefined) return null;
if (choice === OTHER) {
return (await ctx.ui.input(question, "Your answer…")) ?? null;
}
return choice;
}
function askLater(
pi: ExtensionAPI,
ctx: ExtensionToolContext,
details: AskUserDetails,
): void {
dialogChain = dialogChain
.then(async () => {
const answer = await showDialog(ctx, details.question, details.options);
const text =
answer === null
? `[ask_user ${details.id}] dismissed "${details.question}" without an answer — re-ask it, or proceed without it.`
: `[ask_user ${details.id}] answer to "${details.question}": ${answer}`;
// Steer queues the message until the current assistant turn ends;
// if the agent is already idle it starts a new turn instead.
pi.sendUserMessage(text, { deliverAs: "steer" });
})
.catch(() => {
// Keep the chain alive for later questions.
});
}
export default function askUser(pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user",
label: "Ask user",
description:
"Ask the user a question and keep working. Returns immediately with a " +
"pending id; the user answers in a dialog and their answer arrives " +
"later as a user message tagged [ask_user <id>] that repeats the " +
"question — match it by id and act on it then. Continue other work in " +
"this turn while it's pending rather than ending the turn or re-asking. " +
"Pass options to offer a choice (the user can still type a different " +
"answer); omit them for a free-text answer.",
parameters: Type.Object({
question: Type.String({ description: "The question to ask" }),
options: Type.Optional(
Type.Array(Type.String(), {
description: "Choices to offer; omit for a free-text answer",
}),
),
}),
executionMode: "sequential",
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
content: [
{
type: "text",
text: "Error: ask_user has no UI in this mode. Ask the question in your reply instead.",
},
],
isError: true,
details: { id: "", question: params.question, options: [] },
};
}
const details: AskUserDetails = {
id: Math.random().toString(36).slice(2, 6),
question: params.question,
options: (params.options ?? []).filter((o) => o !== OTHER),
};
askLater(pi, ctx, details);
return {
content: [
{
type: "text",
text:
`Question ${details.id} asked: "${details.question}". The user's ` +
`answer arrives later as a user message tagged ` +
`[ask_user ${details.id}] — continue other work until then.`,
},
],
details,
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", args.question);
if (args.options?.length) {
const numbered = [...args.options, OTHER].map((o, i) => `${i + 1}. ${o}`);
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
if (result.isError) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
const details = result.details as AskUserDetails;
return new Text(
theme.fg("muted", `⏳ ${details.id} — answer arrives as a user message`),
0,
0,
);
},
});
}