@howaboua/pi-ask 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/ask/config.ts +43 -0
- package/ask/constants.ts +7 -0
- package/ask/contracts.ts +48 -0
- package/ask/normalize.ts +107 -0
- package/ask/pi-ui.ts +133 -0
- package/ask/state.ts +100 -0
- package/ask/tool.ts +90 -0
- package/ask/tui.ts +542 -0
- package/biome.json +31 -0
- package/index.ts +22 -0
- package/package.json +68 -0
- package/prompts/fold.md +17 -0
- package/prompts/grill.md +33 -0
- package/tsconfig.json +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Warzocha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @howaboua/pi-ask
|
|
2
|
+
|
|
3
|
+
Adds one agent-callable `ask` tool for human input. It handles free-text prompts, batched choices, review dispositions, and user-only handoffs without making the agent dump a report into chat first.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@howaboua/pi-ask
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Try it for one session:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi -e npm:@howaboua/pi-ask
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## How it behaves
|
|
18
|
+
|
|
19
|
+
The agent can present several independently decidable prompts in one tabbed panel. Each prompt supports a short title, supporting evidence, one or more choices, free text, and an optional comment. Review findings become one prompt each, so you can fix, defer, or reject them without translating a wall of prose back into instructions.
|
|
20
|
+
|
|
21
|
+
For work only you can complete—sign-in, authorization, hardware access, or another manual step—the agent can open a handoff and wait until you mark it done or unable to complete.
|
|
22
|
+
|
|
23
|
+
`Other/rephrase` is always available. Submit it blank when the prompt needs to be rephrased, split, or followed up instead of answered as written.
|
|
24
|
+
|
|
25
|
+
## Prompt commands
|
|
26
|
+
|
|
27
|
+
- `/fold [report]` turns a long report or structured list into one interactive disposition prompt per item.
|
|
28
|
+
- `/grill [idea]` investigates an idea, asks successive decisions, and keeps the agreed plan in `docs/`.
|
|
29
|
+
|
|
30
|
+
Both templates are enabled by default. The extension creates `~/.pi/agent/ask.json` on first load:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"grill": true,
|
|
35
|
+
"fold": true
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set either value to `false`, then run `/reload`, to hide that template. The same configuration applies when pi-ask is installed through `@howaboua/pi-extensions` or `@howaboua/pi-stuff`.
|
|
40
|
+
|
|
41
|
+
## TUI controls
|
|
42
|
+
|
|
43
|
+
- `Up` / `Down` — move through choices, free text, and comments
|
|
44
|
+
- `Enter` — choose or edit the focused item
|
|
45
|
+
- `Left` / `Right` — move between prompts and the review tab
|
|
46
|
+
- `Tab` — advance, using `Other/rephrase` when the current prompt is unanswered
|
|
47
|
+
- `Esc` — dismiss the panel
|
|
48
|
+
|
|
49
|
+
The tool requires Pi's interactive TUI or RPC UI. It returns dismissal as a distinct result so the agent can stop asking rather than guessing.
|
package/ask/config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
interface AskConfig {
|
|
6
|
+
grill: boolean;
|
|
7
|
+
fold: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG: AskConfig = {
|
|
11
|
+
grill: true,
|
|
12
|
+
fold: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function askConfigPath(): string {
|
|
16
|
+
return join(getAgentDir(), "ask.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadAskConfig(path = askConfigPath()): AskConfig {
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileSync(
|
|
24
|
+
path,
|
|
25
|
+
`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`,
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
// A read-only agent directory must not disable default prompt resources.
|
|
30
|
+
}
|
|
31
|
+
return { ...DEFAULT_CONFIG };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<AskConfig>;
|
|
36
|
+
return {
|
|
37
|
+
grill: typeof raw.grill === "boolean" ? raw.grill : DEFAULT_CONFIG.grill,
|
|
38
|
+
fold: typeof raw.fold === "boolean" ? raw.fold : DEFAULT_CONFIG.fold,
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...DEFAULT_CONFIG };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/ask/constants.ts
ADDED
package/ask/contracts.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export { OTHER_OPTION_LABEL, REPHRASE_REQUEST_RESPONSE } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
export const ChoiceSchema = Type.Object({
|
|
6
|
+
label: Type.String({ description: "Short choice." }),
|
|
7
|
+
description: Type.Optional(Type.String({ description: "Optional detail." })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const PromptSchema = Type.Object({
|
|
11
|
+
title: Type.String({ description: "Short prompt." }),
|
|
12
|
+
body: Type.Optional(Type.String({ description: "Context or evidence." })),
|
|
13
|
+
multiple: Type.Optional(Type.Boolean({ description: "Allow multiple." })),
|
|
14
|
+
choices: Type.Optional(
|
|
15
|
+
Type.Array(ChoiceSchema, { description: "Choices; omit for free text." }),
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const AskParameters = Type.Object({
|
|
20
|
+
handoff: Type.Optional(
|
|
21
|
+
Type.Boolean({ description: "Wait for user action." }),
|
|
22
|
+
),
|
|
23
|
+
prompts: Type.Array(PromptSchema, { description: "Prompts." }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type AskInput = Static<typeof AskParameters>;
|
|
27
|
+
export type PromptChoice = Static<typeof ChoiceSchema>;
|
|
28
|
+
|
|
29
|
+
export interface AskPrompt {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
body?: string;
|
|
33
|
+
multiple: boolean;
|
|
34
|
+
choices: PromptChoice[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AskResponse {
|
|
38
|
+
id: string;
|
|
39
|
+
selections: string[];
|
|
40
|
+
comment?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PromptState {
|
|
44
|
+
selections: string[];
|
|
45
|
+
customText: string;
|
|
46
|
+
customEnabled: boolean;
|
|
47
|
+
comment: string;
|
|
48
|
+
}
|
package/ask/normalize.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { HANDOFF_CHOICES, OTHER_OPTION_LABEL } from "./constants.js";
|
|
2
|
+
import type {
|
|
3
|
+
AskInput,
|
|
4
|
+
AskPrompt,
|
|
5
|
+
AskResponse,
|
|
6
|
+
PromptChoice,
|
|
7
|
+
} from "./contracts.js";
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function textContent(text: string): { type: "text"; text: string } {
|
|
14
|
+
return { type: "text", text };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizePromptChoice(choice: unknown): PromptChoice | null {
|
|
18
|
+
if (!isRecord(choice)) return null;
|
|
19
|
+
const label =
|
|
20
|
+
typeof choice["label"] === "string" ? choice["label"].trim() : "";
|
|
21
|
+
if (!label || label === OTHER_OPTION_LABEL) return null;
|
|
22
|
+
const description =
|
|
23
|
+
typeof choice["description"] === "string"
|
|
24
|
+
? choice["description"].trim()
|
|
25
|
+
: "";
|
|
26
|
+
return { label, ...(description ? { description } : {}) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizePrompt(
|
|
30
|
+
input: unknown,
|
|
31
|
+
index: number,
|
|
32
|
+
handoff = false,
|
|
33
|
+
): AskPrompt | null {
|
|
34
|
+
if (!isRecord(input)) return null;
|
|
35
|
+
const title = typeof input["title"] === "string" ? input["title"].trim() : "";
|
|
36
|
+
if (!title) return null;
|
|
37
|
+
const body = typeof input["body"] === "string" ? input["body"].trim() : "";
|
|
38
|
+
const choices = Array.isArray(input["choices"])
|
|
39
|
+
? input["choices"]
|
|
40
|
+
.map(normalizePromptChoice)
|
|
41
|
+
.filter((choice) => choice !== null)
|
|
42
|
+
: [];
|
|
43
|
+
return {
|
|
44
|
+
id: `p${index + 1}`,
|
|
45
|
+
title,
|
|
46
|
+
...(body ? { body } : {}),
|
|
47
|
+
multiple: input["multiple"] === true,
|
|
48
|
+
choices: handoff && choices.length === 0 ? [...HANDOFF_CHOICES] : choices,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeAskInput(params: AskInput | null | undefined): {
|
|
53
|
+
handoff: boolean;
|
|
54
|
+
prompts: AskPrompt[];
|
|
55
|
+
} {
|
|
56
|
+
const handoff = params?.handoff === true;
|
|
57
|
+
const prompts = Array.isArray(params?.prompts)
|
|
58
|
+
? params.prompts
|
|
59
|
+
.map((prompt, index) => normalizePrompt(prompt, index, handoff))
|
|
60
|
+
.filter((prompt) => prompt !== null)
|
|
61
|
+
: [];
|
|
62
|
+
return { handoff, prompts };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeResponses(
|
|
66
|
+
prompts: AskPrompt[],
|
|
67
|
+
responses: unknown,
|
|
68
|
+
): AskResponse[] | null {
|
|
69
|
+
if (!Array.isArray(responses)) return null;
|
|
70
|
+
return prompts.map((prompt, index) => {
|
|
71
|
+
const response: unknown = responses[index];
|
|
72
|
+
const rawSelections = Array.isArray(response)
|
|
73
|
+
? response
|
|
74
|
+
: isRecord(response) && Array.isArray(response["selections"])
|
|
75
|
+
? response["selections"]
|
|
76
|
+
: [];
|
|
77
|
+
const selections = rawSelections.filter(
|
|
78
|
+
(selection): selection is string => {
|
|
79
|
+
return typeof selection === "string";
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
const comment =
|
|
83
|
+
isRecord(response) && typeof response["comment"] === "string"
|
|
84
|
+
? response["comment"].trim()
|
|
85
|
+
: "";
|
|
86
|
+
return {
|
|
87
|
+
id: prompt.id,
|
|
88
|
+
selections,
|
|
89
|
+
...(comment ? { comment } : {}),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function summarizeResponses(
|
|
95
|
+
prompts: AskPrompt[],
|
|
96
|
+
responses: AskResponse[],
|
|
97
|
+
): string {
|
|
98
|
+
return responses
|
|
99
|
+
.map((response, index) => {
|
|
100
|
+
const prompt = prompts[index];
|
|
101
|
+
const selection = `${prompt?.title ?? `Prompt ${index + 1}`}: ${response.selections.join(", ") || "No selection"}`;
|
|
102
|
+
return response.comment
|
|
103
|
+
? `${selection}\n Comment: ${response.comment}`
|
|
104
|
+
: selection;
|
|
105
|
+
})
|
|
106
|
+
.join("\n");
|
|
107
|
+
}
|
package/ask/pi-ui.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { OTHER_OPTION_LABEL } from "./constants.js";
|
|
3
|
+
import type { AskPrompt, AskResponse } from "./contracts.js";
|
|
4
|
+
import { customSelectionFor } from "./state.js";
|
|
5
|
+
|
|
6
|
+
function dialogOptions(
|
|
7
|
+
signal?: AbortSignal,
|
|
8
|
+
): { signal: AbortSignal } | undefined {
|
|
9
|
+
return signal ? { signal } : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function askOther(
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
prompt: AskPrompt,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<string | null> {
|
|
17
|
+
const value = await ctx.ui.input(
|
|
18
|
+
prompt.title,
|
|
19
|
+
"Alternative; blank = rephrase",
|
|
20
|
+
dialogOptions(signal),
|
|
21
|
+
);
|
|
22
|
+
return value === undefined ? null : customSelectionFor(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function promptText(prompt: AskPrompt, handoff: boolean): string {
|
|
26
|
+
const text = prompt.body ? `${prompt.title}\n\n${prompt.body}` : prompt.title;
|
|
27
|
+
return handoff ? `Human action needed\n\n${text}` : text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function askComment(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
prompt: AskPrompt,
|
|
33
|
+
signal?: AbortSignal,
|
|
34
|
+
): Promise<string | null> {
|
|
35
|
+
const value = await ctx.ui.input(
|
|
36
|
+
`${prompt.title} — comment`,
|
|
37
|
+
"Optional comment",
|
|
38
|
+
dialogOptions(signal),
|
|
39
|
+
);
|
|
40
|
+
return value === undefined ? null : value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finishSelectionLabelFor(choices: string[]): string {
|
|
44
|
+
const base = "Finish selection";
|
|
45
|
+
const unavailable = new Set([...choices, OTHER_OPTION_LABEL]);
|
|
46
|
+
let label = base;
|
|
47
|
+
let suffix = 2;
|
|
48
|
+
while (unavailable.has(label)) {
|
|
49
|
+
label = `${base} (${suffix})`;
|
|
50
|
+
suffix++;
|
|
51
|
+
}
|
|
52
|
+
return label;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function askSingleWithPiUi(
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
prompt: AskPrompt,
|
|
58
|
+
handoff: boolean,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
): Promise<string[] | null> {
|
|
61
|
+
const choices = prompt.choices.map((choice) => choice.label);
|
|
62
|
+
const picked =
|
|
63
|
+
choices.length > 0
|
|
64
|
+
? await ctx.ui.select(
|
|
65
|
+
promptText(prompt, handoff),
|
|
66
|
+
[...choices, OTHER_OPTION_LABEL],
|
|
67
|
+
dialogOptions(signal),
|
|
68
|
+
)
|
|
69
|
+
: await ctx.ui.input(
|
|
70
|
+
promptText(prompt, handoff),
|
|
71
|
+
"Response",
|
|
72
|
+
dialogOptions(signal),
|
|
73
|
+
);
|
|
74
|
+
if (picked === undefined) return null;
|
|
75
|
+
if (choices.length === 0) return [customSelectionFor(picked)];
|
|
76
|
+
if (picked !== OTHER_OPTION_LABEL) return [picked];
|
|
77
|
+
const other = await askOther(ctx, prompt, signal);
|
|
78
|
+
return other === null ? null : [other];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function askMultipleWithPiUi(
|
|
82
|
+
ctx: ExtensionContext,
|
|
83
|
+
prompt: AskPrompt,
|
|
84
|
+
handoff: boolean,
|
|
85
|
+
signal?: AbortSignal,
|
|
86
|
+
): Promise<string[] | null> {
|
|
87
|
+
const choices = prompt.choices.map((choice) => choice.label);
|
|
88
|
+
const finishSelectionLabel = finishSelectionLabelFor(choices);
|
|
89
|
+
const picked: string[] = [];
|
|
90
|
+
while (true) {
|
|
91
|
+
const next = await ctx.ui.select(
|
|
92
|
+
promptText(prompt, handoff),
|
|
93
|
+
[
|
|
94
|
+
...choices.filter((choice) => !picked.includes(choice)),
|
|
95
|
+
OTHER_OPTION_LABEL,
|
|
96
|
+
finishSelectionLabel,
|
|
97
|
+
],
|
|
98
|
+
dialogOptions(signal),
|
|
99
|
+
);
|
|
100
|
+
if (next === undefined) return null;
|
|
101
|
+
if (next === finishSelectionLabel) return picked;
|
|
102
|
+
if (next === OTHER_OPTION_LABEL) {
|
|
103
|
+
const other = await askOther(ctx, prompt, signal);
|
|
104
|
+
if (other === null) return null;
|
|
105
|
+
picked.push(other);
|
|
106
|
+
} else {
|
|
107
|
+
picked.push(next);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function askWithPiUi(
|
|
113
|
+
ctx: ExtensionContext,
|
|
114
|
+
prompts: AskPrompt[],
|
|
115
|
+
{ handoff = false, signal }: { handoff?: boolean; signal?: AbortSignal } = {},
|
|
116
|
+
): Promise<AskResponse[] | null> {
|
|
117
|
+
if (!ctx.hasUI || signal?.aborted) return null;
|
|
118
|
+
const responses: AskResponse[] = [];
|
|
119
|
+
for (const prompt of prompts) {
|
|
120
|
+
const selections = prompt.multiple
|
|
121
|
+
? await askMultipleWithPiUi(ctx, prompt, handoff, signal)
|
|
122
|
+
: await askSingleWithPiUi(ctx, prompt, handoff, signal);
|
|
123
|
+
if (!selections) return null;
|
|
124
|
+
const comment = await askComment(ctx, prompt, signal);
|
|
125
|
+
if (comment === null || signal?.aborted) return null;
|
|
126
|
+
responses.push({
|
|
127
|
+
id: prompt.id,
|
|
128
|
+
selections,
|
|
129
|
+
...(comment ? { comment } : {}),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return responses;
|
|
133
|
+
}
|
package/ask/state.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { REPHRASE_REQUEST_RESPONSE } from "./constants.js";
|
|
2
|
+
import type {
|
|
3
|
+
AskPrompt,
|
|
4
|
+
AskResponse,
|
|
5
|
+
PromptChoice,
|
|
6
|
+
PromptState,
|
|
7
|
+
} from "./contracts.js";
|
|
8
|
+
|
|
9
|
+
export function customSelectionFor(value: unknown): string {
|
|
10
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
11
|
+
return trimmed || REPHRASE_REQUEST_RESPONSE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPromptState(): PromptState {
|
|
15
|
+
return { selections: [], customText: "", customEnabled: false, comment: "" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function promptStateResponded(
|
|
19
|
+
promptState: PromptState | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
return (
|
|
22
|
+
(promptState?.selections.length ?? 0) > 0 ||
|
|
23
|
+
promptState?.customEnabled === true ||
|
|
24
|
+
Boolean(promptState?.comment.trim())
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function promptStatesToResponses(
|
|
29
|
+
prompts: Array<Pick<AskPrompt, "id">>,
|
|
30
|
+
promptStates: PromptState[],
|
|
31
|
+
): AskResponse[] {
|
|
32
|
+
return promptStates.map((promptState, index) => {
|
|
33
|
+
const comment = promptState.comment.trim();
|
|
34
|
+
return {
|
|
35
|
+
id: prompts[index]?.id ?? `p${index + 1}`,
|
|
36
|
+
selections: promptState.selections,
|
|
37
|
+
...(comment ? { comment } : {}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveComment(
|
|
43
|
+
promptState: PromptState,
|
|
44
|
+
submittedText: unknown,
|
|
45
|
+
): void {
|
|
46
|
+
promptState.comment = typeof submittedText === "string" ? submittedText : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveCustomSelection(
|
|
50
|
+
prompt: Pick<AskPrompt, "multiple"> | undefined,
|
|
51
|
+
promptState: PromptState,
|
|
52
|
+
submittedText: unknown,
|
|
53
|
+
): void {
|
|
54
|
+
const previous = promptState.customEnabled
|
|
55
|
+
? customSelectionFor(promptState.customText)
|
|
56
|
+
: null;
|
|
57
|
+
const value = typeof submittedText === "string" ? submittedText : "";
|
|
58
|
+
const next = customSelectionFor(value);
|
|
59
|
+
|
|
60
|
+
promptState.customText = value;
|
|
61
|
+
promptState.customEnabled = true;
|
|
62
|
+
|
|
63
|
+
if (prompt?.multiple) {
|
|
64
|
+
promptState.selections = [
|
|
65
|
+
...promptState.selections.filter(
|
|
66
|
+
(item) => item !== next && item !== previous,
|
|
67
|
+
),
|
|
68
|
+
next,
|
|
69
|
+
];
|
|
70
|
+
} else {
|
|
71
|
+
promptState.selections = [next];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearCustomSelection(promptState: PromptState): void {
|
|
76
|
+
if (!promptState.customEnabled) return;
|
|
77
|
+
const customSelection = customSelectionFor(promptState.customText);
|
|
78
|
+
promptState.selections = promptState.selections.filter(
|
|
79
|
+
(item) => item !== customSelection,
|
|
80
|
+
);
|
|
81
|
+
promptState.customText = "";
|
|
82
|
+
promptState.customEnabled = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function pickChoiceSelection(
|
|
86
|
+
prompt: Pick<AskPrompt, "multiple"> | undefined,
|
|
87
|
+
promptState: PromptState,
|
|
88
|
+
choice: PromptChoice | undefined,
|
|
89
|
+
): void {
|
|
90
|
+
if (!choice) return;
|
|
91
|
+
if (prompt?.multiple) {
|
|
92
|
+
promptState.selections = promptState.selections.includes(choice.label)
|
|
93
|
+
? promptState.selections.filter((item) => item !== choice.label)
|
|
94
|
+
: [...promptState.selections, choice.label];
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
promptState.selections = [choice.label];
|
|
99
|
+
promptState.customEnabled = false;
|
|
100
|
+
}
|
package/ask/tool.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { AskParameters, type AskPrompt } from "./contracts.js";
|
|
4
|
+
import {
|
|
5
|
+
normalizeAskInput,
|
|
6
|
+
normalizeResponses,
|
|
7
|
+
summarizeResponses,
|
|
8
|
+
textContent,
|
|
9
|
+
} from "./normalize.js";
|
|
10
|
+
import { askWithPiUi } from "./pi-ui.js";
|
|
11
|
+
import { askInTui } from "./tui.js";
|
|
12
|
+
|
|
13
|
+
type AskInComposer = (
|
|
14
|
+
prompts: AskPrompt[],
|
|
15
|
+
signal: AbortSignal | undefined,
|
|
16
|
+
) => Promise<unknown>;
|
|
17
|
+
|
|
18
|
+
export function createAskTool({
|
|
19
|
+
askInComposer,
|
|
20
|
+
}: {
|
|
21
|
+
askInComposer?: AskInComposer;
|
|
22
|
+
} = {}) {
|
|
23
|
+
return defineTool({
|
|
24
|
+
name: "ask",
|
|
25
|
+
label: "Ask",
|
|
26
|
+
description:
|
|
27
|
+
"Request user input or action and return the response. Requires interactive UI.",
|
|
28
|
+
parameters: AskParameters,
|
|
29
|
+
promptSnippet: "ask: Request human input or action.",
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
"ask: Use for needed user decisions or input; set handoff true for a user-only action and state its completion signal.",
|
|
32
|
+
"ask: For reviews, make each finding a prompt with disposition choices; do not report first.",
|
|
33
|
+
"ask: Do not add Other/rephrase; it is automatic.",
|
|
34
|
+
],
|
|
35
|
+
executionMode: "sequential",
|
|
36
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
37
|
+
const { handoff, prompts } = normalizeAskInput(params);
|
|
38
|
+
if (prompts.length === 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"ask requires at least one prompt with a non-empty title.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (!askInComposer && !ctx.hasUI)
|
|
44
|
+
throw new Error("ask requires an interactive UI.");
|
|
45
|
+
|
|
46
|
+
const rawResponses = askInComposer
|
|
47
|
+
? await askInComposer(prompts, signal)
|
|
48
|
+
: ctx.mode === "tui"
|
|
49
|
+
? await askInTui(ctx, prompts, {
|
|
50
|
+
handoff,
|
|
51
|
+
...(signal ? { signal } : {}),
|
|
52
|
+
})
|
|
53
|
+
: await askWithPiUi(ctx, prompts, {
|
|
54
|
+
handoff,
|
|
55
|
+
...(signal ? { signal } : {}),
|
|
56
|
+
});
|
|
57
|
+
const responses = normalizeResponses(prompts, rawResponses);
|
|
58
|
+
if (!responses) {
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
textContent(
|
|
62
|
+
handoff ? "Handoff dismissed by user." : "Dismissed by user.",
|
|
63
|
+
),
|
|
64
|
+
],
|
|
65
|
+
details: { dismissed: true, kind: handoff ? "handoff" : "prompt" },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [textContent(summarizeResponses(prompts, responses))],
|
|
70
|
+
details: { kind: handoff ? "handoff" : "prompt", responses },
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
renderCall(args, theme) {
|
|
74
|
+
const count = Array.isArray(args.prompts) ? args.prompts.length : 0;
|
|
75
|
+
return new Text(
|
|
76
|
+
theme.fg(
|
|
77
|
+
"toolTitle",
|
|
78
|
+
theme.bold(args.handoff === true ? "ask handoff " : "ask "),
|
|
79
|
+
) + theme.fg("muted", `${count} prompt${count === 1 ? "" : "s"}`),
|
|
80
|
+
0,
|
|
81
|
+
0,
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
renderResult(result, _options, theme) {
|
|
85
|
+
const text =
|
|
86
|
+
result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
87
|
+
return new Text(theme.fg("success", text), 0, 0);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
package/ask/tui.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Editor,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { OTHER_OPTION_LABEL } from "./constants.js";
|
|
10
|
+
import type {
|
|
11
|
+
AskPrompt,
|
|
12
|
+
AskResponse,
|
|
13
|
+
PromptChoice,
|
|
14
|
+
PromptState,
|
|
15
|
+
} from "./contracts.js";
|
|
16
|
+
import {
|
|
17
|
+
clearCustomSelection,
|
|
18
|
+
createPromptState,
|
|
19
|
+
pickChoiceSelection,
|
|
20
|
+
promptStateResponded,
|
|
21
|
+
promptStatesToResponses,
|
|
22
|
+
saveComment,
|
|
23
|
+
saveCustomSelection,
|
|
24
|
+
} from "./state.js";
|
|
25
|
+
|
|
26
|
+
type AskTheme = ExtensionContext["ui"]["theme"];
|
|
27
|
+
type AddLine = (line?: string) => void;
|
|
28
|
+
type EditingKind = "other" | "comment";
|
|
29
|
+
|
|
30
|
+
interface AskUiState {
|
|
31
|
+
tab: number;
|
|
32
|
+
focus: number;
|
|
33
|
+
editing: EditingKind | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RenderTabsOptions {
|
|
37
|
+
handoff: boolean;
|
|
38
|
+
prompts: AskPrompt[];
|
|
39
|
+
responded: (index: number) => boolean;
|
|
40
|
+
tab: number;
|
|
41
|
+
theme: AskTheme;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RenderReviewOptions {
|
|
45
|
+
add: AddLine;
|
|
46
|
+
handoff: boolean;
|
|
47
|
+
promptStates: PromptState[];
|
|
48
|
+
prompts: AskPrompt[];
|
|
49
|
+
theme: AskTheme;
|
|
50
|
+
width: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RenderChoiceOptions {
|
|
54
|
+
add: AddLine;
|
|
55
|
+
checked: boolean | undefined;
|
|
56
|
+
choice: PromptChoice;
|
|
57
|
+
selected: boolean;
|
|
58
|
+
theme: AskTheme;
|
|
59
|
+
width: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RenderPromptOptions {
|
|
63
|
+
add: AddLine;
|
|
64
|
+
editor: Editor;
|
|
65
|
+
handoff: boolean;
|
|
66
|
+
isEditing: EditingKind | null;
|
|
67
|
+
choices: PromptChoice[];
|
|
68
|
+
prompt: AskPrompt;
|
|
69
|
+
promptState: PromptState | undefined;
|
|
70
|
+
state: AskUiState;
|
|
71
|
+
theme: AskTheme;
|
|
72
|
+
width: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addWrapped(
|
|
76
|
+
add: AddLine,
|
|
77
|
+
text: string,
|
|
78
|
+
width: number,
|
|
79
|
+
indent = "",
|
|
80
|
+
): void {
|
|
81
|
+
for (const line of wrapTextWithAnsi(
|
|
82
|
+
text,
|
|
83
|
+
Math.max(1, width - indent.length),
|
|
84
|
+
)) {
|
|
85
|
+
add(`${indent}${line}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderAskTabs({
|
|
90
|
+
handoff,
|
|
91
|
+
prompts,
|
|
92
|
+
responded,
|
|
93
|
+
tab,
|
|
94
|
+
theme,
|
|
95
|
+
}: RenderTabsOptions): string {
|
|
96
|
+
return ` ${Array.from({ length: prompts.length + 1 }, (_, index) => {
|
|
97
|
+
const active = index === tab;
|
|
98
|
+
const label =
|
|
99
|
+
index === prompts.length
|
|
100
|
+
? handoff
|
|
101
|
+
? "Resume"
|
|
102
|
+
: "Review"
|
|
103
|
+
: `${responded(index) ? "■" : "□"} ${index + 1}`;
|
|
104
|
+
return active
|
|
105
|
+
? theme.bg("selectedBg", theme.fg("text", ` ${label} `))
|
|
106
|
+
: theme.fg("muted", ` ${label} `);
|
|
107
|
+
}).join(" ")}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderAskReview({
|
|
111
|
+
add,
|
|
112
|
+
handoff,
|
|
113
|
+
promptStates,
|
|
114
|
+
prompts,
|
|
115
|
+
theme,
|
|
116
|
+
width,
|
|
117
|
+
}: RenderReviewOptions): void {
|
|
118
|
+
add(
|
|
119
|
+
theme.fg(
|
|
120
|
+
handoff ? "warning" : "accent",
|
|
121
|
+
handoff ? " Resume agent" : " Review",
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
add();
|
|
125
|
+
prompts.forEach((prompt, index) => {
|
|
126
|
+
const promptState = promptStates[index];
|
|
127
|
+
const selections = promptState?.selections ?? [];
|
|
128
|
+
addWrapped(
|
|
129
|
+
add,
|
|
130
|
+
theme.fg("muted", `${index + 1}. ${prompt.title}`),
|
|
131
|
+
width,
|
|
132
|
+
" ",
|
|
133
|
+
);
|
|
134
|
+
addWrapped(
|
|
135
|
+
add,
|
|
136
|
+
theme.fg("text", selections.join(", ") || "No selection"),
|
|
137
|
+
width,
|
|
138
|
+
" ",
|
|
139
|
+
);
|
|
140
|
+
if (promptState?.comment.trim()) {
|
|
141
|
+
addWrapped(
|
|
142
|
+
add,
|
|
143
|
+
theme.fg("muted", `Comment: ${promptState.comment.trim()}`),
|
|
144
|
+
width,
|
|
145
|
+
" ",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
add();
|
|
150
|
+
add(
|
|
151
|
+
theme.fg(
|
|
152
|
+
"dim",
|
|
153
|
+
handoff
|
|
154
|
+
? " Enter returns control • ←/→ prompts • Esc dismisses"
|
|
155
|
+
: " Enter submits • ←/→ prompts • Esc dismisses",
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderAskChoice({
|
|
161
|
+
add,
|
|
162
|
+
checked,
|
|
163
|
+
choice,
|
|
164
|
+
selected,
|
|
165
|
+
theme,
|
|
166
|
+
width,
|
|
167
|
+
}: RenderChoiceOptions): void {
|
|
168
|
+
add(
|
|
169
|
+
`${selected ? theme.fg("accent", "> ") : " "}${checked ? theme.fg("success", "✓ ") : " "}${theme.fg(selected ? "accent" : "text", choice.label)}`,
|
|
170
|
+
);
|
|
171
|
+
if (choice.description) {
|
|
172
|
+
addWrapped(add, theme.fg("muted", choice.description), width, " ");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderAskPrompt({
|
|
177
|
+
add,
|
|
178
|
+
editor,
|
|
179
|
+
handoff,
|
|
180
|
+
isEditing,
|
|
181
|
+
choices,
|
|
182
|
+
prompt,
|
|
183
|
+
promptState,
|
|
184
|
+
state,
|
|
185
|
+
theme,
|
|
186
|
+
width,
|
|
187
|
+
}: RenderPromptOptions): void {
|
|
188
|
+
if (handoff) {
|
|
189
|
+
add(theme.fg("warning", " Human action needed"));
|
|
190
|
+
add();
|
|
191
|
+
}
|
|
192
|
+
addWrapped(add, theme.fg("text", prompt.title), width, " ");
|
|
193
|
+
add(
|
|
194
|
+
theme.fg(
|
|
195
|
+
"muted",
|
|
196
|
+
` ${prompt.multiple ? "Choose any that apply" : "Choose one"}`,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
if (prompt.body) {
|
|
200
|
+
add();
|
|
201
|
+
addWrapped(add, theme.fg("muted", prompt.body), width, " ");
|
|
202
|
+
}
|
|
203
|
+
add();
|
|
204
|
+
choices.forEach((choice, index) => {
|
|
205
|
+
renderAskChoice({
|
|
206
|
+
add,
|
|
207
|
+
checked: promptState?.selections.includes(choice.label),
|
|
208
|
+
choice,
|
|
209
|
+
selected: state.focus === index,
|
|
210
|
+
theme,
|
|
211
|
+
width,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
const selectedOther = state.focus === choices.length;
|
|
215
|
+
add(
|
|
216
|
+
`${selectedOther ? theme.fg("accent", "> ") : " "}${promptState?.customEnabled ? theme.fg("success", "✓ ") : " "}${theme.fg(selectedOther ? "accent" : "muted", OTHER_OPTION_LABEL)}${promptState?.customText.trim() ? theme.fg("text", `: ${promptState.customText.trim()}`) : ""}`,
|
|
217
|
+
);
|
|
218
|
+
if (isEditing === "other") {
|
|
219
|
+
add();
|
|
220
|
+
for (const line of editor.render(width - 2)) add(` ${line}`);
|
|
221
|
+
}
|
|
222
|
+
const selectedComment = state.focus === choices.length + 1;
|
|
223
|
+
add();
|
|
224
|
+
add(
|
|
225
|
+
`${selectedComment ? theme.fg("accent", "> ") : " "}${theme.fg(selectedComment ? "accent" : "text", "Comment (optional)")}`,
|
|
226
|
+
);
|
|
227
|
+
if (promptState?.comment.trim()) {
|
|
228
|
+
addWrapped(
|
|
229
|
+
add,
|
|
230
|
+
theme.fg("muted", promptState.comment.trim()),
|
|
231
|
+
width,
|
|
232
|
+
" ",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (isEditing === "comment") {
|
|
236
|
+
add();
|
|
237
|
+
for (const line of editor.render(width - 2)) add(` ${line}`);
|
|
238
|
+
}
|
|
239
|
+
add();
|
|
240
|
+
add(
|
|
241
|
+
theme.fg(
|
|
242
|
+
"dim",
|
|
243
|
+
" ↑↓ select • Enter choose/type • Tab next/default • blank Other/rephrase = follow-up • Esc close",
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface EditingInputOptions {
|
|
249
|
+
advance: () => void;
|
|
250
|
+
editor: Editor;
|
|
251
|
+
refresh: () => void;
|
|
252
|
+
saveEditing: (submittedText?: string) => void;
|
|
253
|
+
state: AskUiState;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function handleAskEditingInput(
|
|
257
|
+
data: string,
|
|
258
|
+
{ advance, editor, refresh, saveEditing, state }: EditingInputOptions,
|
|
259
|
+
): void {
|
|
260
|
+
if (matchesKey(data, Key.tab)) {
|
|
261
|
+
saveEditing(editor.getText());
|
|
262
|
+
state.editing = null;
|
|
263
|
+
advance();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (matchesKey(data, Key.escape)) {
|
|
267
|
+
state.editing = null;
|
|
268
|
+
refresh();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
editor.handleInput(data);
|
|
272
|
+
refresh();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface PromptInputOptions {
|
|
276
|
+
advanceWithDefault: () => void;
|
|
277
|
+
choices: () => PromptChoice[];
|
|
278
|
+
count: () => number;
|
|
279
|
+
pick: (index: number) => void;
|
|
280
|
+
refresh: () => void;
|
|
281
|
+
selectOther: () => void;
|
|
282
|
+
startEditingComment: () => void;
|
|
283
|
+
state: AskUiState;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function handleAskPromptInput(
|
|
287
|
+
data: string,
|
|
288
|
+
{
|
|
289
|
+
advanceWithDefault,
|
|
290
|
+
choices,
|
|
291
|
+
count,
|
|
292
|
+
pick,
|
|
293
|
+
refresh,
|
|
294
|
+
selectOther,
|
|
295
|
+
startEditingComment,
|
|
296
|
+
state,
|
|
297
|
+
}: PromptInputOptions,
|
|
298
|
+
): void {
|
|
299
|
+
if (matchesKey(data, Key.up)) {
|
|
300
|
+
state.focus = Math.max(0, state.focus - 1);
|
|
301
|
+
refresh();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (matchesKey(data, Key.down)) {
|
|
305
|
+
state.focus = Math.min(count() - 1, state.focus + 1);
|
|
306
|
+
refresh();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (matchesKey(data, Key.tab)) {
|
|
310
|
+
advanceWithDefault();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!matchesKey(data, Key.enter)) return;
|
|
314
|
+
if (state.focus === choices().length) {
|
|
315
|
+
selectOther();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (state.focus === choices().length + 1) {
|
|
319
|
+
startEditingComment();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
pick(state.focus);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface NavigationInputOptions {
|
|
326
|
+
done: (responses: AskResponse[] | null) => void;
|
|
327
|
+
isReview: () => boolean;
|
|
328
|
+
promptStates: PromptState[];
|
|
329
|
+
prompts: AskPrompt[];
|
|
330
|
+
setTab: (next: number) => void;
|
|
331
|
+
state: AskUiState;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function handleAskNavigationInput(
|
|
335
|
+
data: string,
|
|
336
|
+
{
|
|
337
|
+
done,
|
|
338
|
+
isReview,
|
|
339
|
+
promptStates,
|
|
340
|
+
prompts,
|
|
341
|
+
setTab,
|
|
342
|
+
state,
|
|
343
|
+
}: NavigationInputOptions,
|
|
344
|
+
): boolean {
|
|
345
|
+
if (matchesKey(data, Key.escape)) {
|
|
346
|
+
done(null);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (matchesKey(data, Key.left)) {
|
|
350
|
+
setTab(state.tab - 1);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (matchesKey(data, Key.right)) {
|
|
354
|
+
setTab(state.tab + 1);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
if (!isReview()) return false;
|
|
358
|
+
if (matchesKey(data, Key.enter)) {
|
|
359
|
+
done(promptStatesToResponses(prompts, promptStates));
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function askInTui(
|
|
365
|
+
ctx: ExtensionContext,
|
|
366
|
+
prompts: AskPrompt[],
|
|
367
|
+
{ handoff = false, signal }: { handoff?: boolean; signal?: AbortSignal } = {},
|
|
368
|
+
): Promise<AskResponse[] | null> {
|
|
369
|
+
if (!ctx.hasUI || signal?.aborted) return null;
|
|
370
|
+
return await ctx.ui.custom<AskResponse[] | null>((tui, theme, _kb, done) => {
|
|
371
|
+
let settled = false;
|
|
372
|
+
const finish = (result: AskResponse[] | null) => {
|
|
373
|
+
if (settled) return;
|
|
374
|
+
settled = true;
|
|
375
|
+
signal?.removeEventListener("abort", abort);
|
|
376
|
+
done(result);
|
|
377
|
+
};
|
|
378
|
+
const abort = () => finish(null);
|
|
379
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
380
|
+
if (signal?.aborted) queueMicrotask(abort);
|
|
381
|
+
const state: AskUiState = { tab: 0, focus: 0, editing: null };
|
|
382
|
+
let cached: string[] | undefined;
|
|
383
|
+
// Keep response lifecycle in one object per prompt. Parallel arrays made tab/default
|
|
384
|
+
// transitions easy to desynchronise as this tool grows.
|
|
385
|
+
const promptStates = prompts.map(createPromptState);
|
|
386
|
+
const editor = new Editor(tui, {
|
|
387
|
+
borderColor: (text) => theme.fg(handoff ? "warning" : "accent", text),
|
|
388
|
+
selectList: {
|
|
389
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
390
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
391
|
+
description: (text) => theme.fg("muted", text),
|
|
392
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
393
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const reviewTab = () => prompts.length;
|
|
398
|
+
const current = () => prompts[state.tab];
|
|
399
|
+
const currentPromptState = () => promptStates[state.tab];
|
|
400
|
+
const choices = () => current()?.choices ?? [];
|
|
401
|
+
const isReview = () => state.tab === reviewTab();
|
|
402
|
+
const count = () => choices().length + 2;
|
|
403
|
+
const refresh = () => {
|
|
404
|
+
cached = undefined;
|
|
405
|
+
tui.requestRender();
|
|
406
|
+
};
|
|
407
|
+
const responded = (index: number) =>
|
|
408
|
+
promptStateResponded(promptStates[index]);
|
|
409
|
+
const setTab = (next: number) => {
|
|
410
|
+
state.tab = Math.max(0, Math.min(reviewTab(), next));
|
|
411
|
+
state.focus = 0;
|
|
412
|
+
state.editing = null;
|
|
413
|
+
editor.setText(promptStates[state.tab]?.customText ?? "");
|
|
414
|
+
refresh();
|
|
415
|
+
};
|
|
416
|
+
const saveCustom = (submittedText = editor.getText()) => {
|
|
417
|
+
const prompt = current();
|
|
418
|
+
const promptState = currentPromptState();
|
|
419
|
+
if (!prompt || !promptState) return;
|
|
420
|
+
saveCustomSelection(prompt, promptState, submittedText);
|
|
421
|
+
};
|
|
422
|
+
const saveEditing = (submittedText = editor.getText()) => {
|
|
423
|
+
const promptState = currentPromptState();
|
|
424
|
+
if (!promptState) return;
|
|
425
|
+
if (state.editing === "comment") saveComment(promptState, submittedText);
|
|
426
|
+
else saveCustom(submittedText);
|
|
427
|
+
};
|
|
428
|
+
const startEditing = (kind: EditingKind) => {
|
|
429
|
+
const promptState = currentPromptState();
|
|
430
|
+
if (!promptState) return;
|
|
431
|
+
state.editing = kind;
|
|
432
|
+
editor.setText(
|
|
433
|
+
kind === "comment" ? promptState.comment : promptState.customText,
|
|
434
|
+
);
|
|
435
|
+
refresh();
|
|
436
|
+
};
|
|
437
|
+
const selectOther = () => {
|
|
438
|
+
const prompt = current();
|
|
439
|
+
const promptState = currentPromptState();
|
|
440
|
+
if (!prompt || !promptState) return;
|
|
441
|
+
if (prompt.multiple && promptState.customEnabled) {
|
|
442
|
+
clearCustomSelection(promptState);
|
|
443
|
+
refresh();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
startEditing("other");
|
|
447
|
+
};
|
|
448
|
+
const advance = () => setTab(state.tab + 1);
|
|
449
|
+
const advanceWithDefault = () => {
|
|
450
|
+
if (!isReview() && !responded(state.tab)) saveCustom("");
|
|
451
|
+
advance();
|
|
452
|
+
};
|
|
453
|
+
editor.onSubmit = (value) => {
|
|
454
|
+
saveEditing(value);
|
|
455
|
+
state.editing = null;
|
|
456
|
+
refresh();
|
|
457
|
+
};
|
|
458
|
+
const pick = (index: number) => {
|
|
459
|
+
const choice = choices()[index];
|
|
460
|
+
const prompt = current();
|
|
461
|
+
const promptState = currentPromptState();
|
|
462
|
+
if (!choice || !promptState) return;
|
|
463
|
+
pickChoiceSelection(prompt, promptState, choice);
|
|
464
|
+
refresh();
|
|
465
|
+
};
|
|
466
|
+
const handleInput = (data: string) => {
|
|
467
|
+
if (state.editing) {
|
|
468
|
+
handleAskEditingInput(data, {
|
|
469
|
+
advance,
|
|
470
|
+
editor,
|
|
471
|
+
refresh,
|
|
472
|
+
saveEditing,
|
|
473
|
+
state,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (
|
|
478
|
+
handleAskNavigationInput(data, {
|
|
479
|
+
done: finish,
|
|
480
|
+
isReview,
|
|
481
|
+
promptStates,
|
|
482
|
+
prompts,
|
|
483
|
+
setTab,
|
|
484
|
+
state,
|
|
485
|
+
})
|
|
486
|
+
) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
handleAskPromptInput(data, {
|
|
490
|
+
advanceWithDefault,
|
|
491
|
+
count,
|
|
492
|
+
choices,
|
|
493
|
+
pick,
|
|
494
|
+
refresh,
|
|
495
|
+
selectOther,
|
|
496
|
+
startEditingComment: () => startEditing("comment"),
|
|
497
|
+
state,
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
const render = (width: number) => {
|
|
501
|
+
if (cached) return cached;
|
|
502
|
+
const lines: string[] = [];
|
|
503
|
+
const add: AddLine = (line = "") =>
|
|
504
|
+
lines.push(truncateToWidth(line, width));
|
|
505
|
+
add(theme.fg(handoff ? "warning" : "accent", "─".repeat(width)));
|
|
506
|
+
add(
|
|
507
|
+
renderAskTabs({ handoff, prompts, responded, tab: state.tab, theme }),
|
|
508
|
+
);
|
|
509
|
+
add();
|
|
510
|
+
if (isReview()) {
|
|
511
|
+
renderAskReview({ add, handoff, promptStates, prompts, theme, width });
|
|
512
|
+
} else {
|
|
513
|
+
const prompt = current();
|
|
514
|
+
if (prompt) {
|
|
515
|
+
renderAskPrompt({
|
|
516
|
+
add,
|
|
517
|
+
editor,
|
|
518
|
+
handoff,
|
|
519
|
+
isEditing: state.editing,
|
|
520
|
+
choices: choices(),
|
|
521
|
+
prompt,
|
|
522
|
+
promptState: currentPromptState(),
|
|
523
|
+
state,
|
|
524
|
+
theme,
|
|
525
|
+
width,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
add(theme.fg(handoff ? "warning" : "accent", "─".repeat(width)));
|
|
530
|
+
cached = lines;
|
|
531
|
+
return lines;
|
|
532
|
+
};
|
|
533
|
+
return {
|
|
534
|
+
render,
|
|
535
|
+
handleInput,
|
|
536
|
+
dispose: () => signal?.removeEventListener("abort", abort),
|
|
537
|
+
invalidate: () => {
|
|
538
|
+
cached = undefined;
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
}
|
package/biome.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"includes": [
|
|
10
|
+
"**",
|
|
11
|
+
"!node_modules",
|
|
12
|
+
"!dist",
|
|
13
|
+
"!coverage",
|
|
14
|
+
"!package-lock.json"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"formatter": {
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"indentStyle": "tab"
|
|
20
|
+
},
|
|
21
|
+
"linter": {
|
|
22
|
+
"enabled": false
|
|
23
|
+
},
|
|
24
|
+
"javascript": {
|
|
25
|
+
"formatter": {
|
|
26
|
+
"quoteStyle": "double",
|
|
27
|
+
"semicolons": "always",
|
|
28
|
+
"trailingCommas": "all"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { loadAskConfig } from "./ask/config.js";
|
|
5
|
+
import { createAskTool } from "./ask/tool.js";
|
|
6
|
+
|
|
7
|
+
export { createAskTool } from "./ask/tool.js";
|
|
8
|
+
|
|
9
|
+
const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
10
|
+
|
|
11
|
+
export default function humanInTheLoop(pi: ExtensionAPI): void {
|
|
12
|
+
pi.registerTool(createAskTool());
|
|
13
|
+
pi.on("resources_discover", () => {
|
|
14
|
+
const config = loadAskConfig();
|
|
15
|
+
return {
|
|
16
|
+
promptPaths: [
|
|
17
|
+
...(config.grill ? [join(PROMPTS_DIR, "grill.md")] : []),
|
|
18
|
+
...(config.fold ? [join(PROMPTS_DIR, "fold.md")] : []),
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@howaboua/pi-ask",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Interactive decisions, review triage, and human handoffs for Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Igor Warzocha",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/IgorWarzocha/howaboua-pi-stuff.git",
|
|
11
|
+
"directory": "packages/pi-ask"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/IgorWarzocha/howaboua-pi-stuff/tree/main/packages/pi-ask#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/IgorWarzocha/howaboua-pi-stuff/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"human-in-the-loop",
|
|
22
|
+
"review",
|
|
23
|
+
"handoff"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.6.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.ts",
|
|
33
|
+
"ask",
|
|
34
|
+
"prompts",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"tsconfig.json",
|
|
38
|
+
"biome.json"
|
|
39
|
+
],
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./index.ts"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": ">=0.80.7",
|
|
47
|
+
"@earendil-works/pi-tui": ">=0.80.7",
|
|
48
|
+
"typebox": "*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@biomejs/biome": "^2.4.16",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.80.7",
|
|
53
|
+
"@earendil-works/pi-tui": "0.80.7",
|
|
54
|
+
"typebox": "1.1.38",
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"check": "bun run lint && bun run typecheck && bun run test",
|
|
59
|
+
"prepack": "bun run check",
|
|
60
|
+
"test": "bun test",
|
|
61
|
+
"typecheck": "tsgo --noEmit",
|
|
62
|
+
"lint": "biome check .",
|
|
63
|
+
"format": "biome check --write .",
|
|
64
|
+
"pack:dry": "npm pack --dry-run"
|
|
65
|
+
},
|
|
66
|
+
"main": "index.ts",
|
|
67
|
+
"exports": "./index.ts"
|
|
68
|
+
}
|
package/prompts/fold.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Fold a long report or list into interactive review
|
|
3
|
+
argument-hint: "[report or instructions]"
|
|
4
|
+
---
|
|
5
|
+
Fold this material into `ask` instead of replying with another long report:
|
|
6
|
+
|
|
7
|
+
$ARGUMENTS
|
|
8
|
+
|
|
9
|
+
If no material follows the command, use the most recent lengthy report or structured list in the conversation.
|
|
10
|
+
|
|
11
|
+
Call `ask` once with one prompt per independently decidable item:
|
|
12
|
+
- Keep the title short.
|
|
13
|
+
- Preserve concrete evidence, consequences, and recommendations in the body.
|
|
14
|
+
- Use the source's natural choices. For review findings, default to Fix, Defer, and Reject.
|
|
15
|
+
- Keep coupled points together; do not manufacture decisions from explanatory bullets.
|
|
16
|
+
|
|
17
|
+
Do not repeat the report before or after the tool call. Once the responses return, summarize the dispositions briefly and continue only when the surrounding task already authorizes action.
|
package/prompts/grill.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Grill an idea into an agreed plan
|
|
3
|
+
argument-hint: "[idea or plan]"
|
|
4
|
+
---
|
|
5
|
+
Grill this into an agreed plan:
|
|
6
|
+
|
|
7
|
+
$ARGUMENTS
|
|
8
|
+
|
|
9
|
+
Use the working directory, web search, available skills, and any relevant tools to understand the thing before asking. Prefer finding answers yourself over asking me.
|
|
10
|
+
|
|
11
|
+
Loop until ask is dismissed. There is no round limit:
|
|
12
|
+
1. Investigate the relevant context.
|
|
13
|
+
2. Use ask for the next set of decisions.
|
|
14
|
+
3. After the first answer round, create docs/<short-name>.md.
|
|
15
|
+
4. Keep asking better questions, using tools between rounds as needed.
|
|
16
|
+
5. Keep updating the markdown file after each round.
|
|
17
|
+
|
|
18
|
+
The markdown file is the current plan, not a transcript. Keep it ready to use if I stop at any point. Include:
|
|
19
|
+
- goal
|
|
20
|
+
- agreed decisions
|
|
21
|
+
- important tradeoffs
|
|
22
|
+
- relevant user reasoning
|
|
23
|
+
- open questions
|
|
24
|
+
- proposed next steps
|
|
25
|
+
|
|
26
|
+
Do not log every question and answer.
|
|
27
|
+
|
|
28
|
+
When ask is dismissed, the grilling session is done. Then:
|
|
29
|
+
- give a short summary of what was agreed
|
|
30
|
+
- open the markdown file with $EDITOR if available
|
|
31
|
+
- print the file path
|
|
32
|
+
|
|
33
|
+
Do not start implementation unless I explicitly choose to proceed.
|
package/tsconfig.json
ADDED