@mrclrchtr/supi-ask-user 1.3.1 → 1.4.0
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/README.md +125 -67
- package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
- package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
- package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
- package/package.json +2 -2
- package/src/api.ts +19 -0
- package/src/ask-user.ts +65 -131
- package/src/index.ts +23 -1
- package/src/normalize.ts +153 -142
- package/src/render/result.ts +98 -0
- package/src/render/transcript.ts +65 -0
- package/src/render/tree-summary.ts +10 -0
- package/src/schema.ts +41 -38
- package/src/session/controller.ts +163 -0
- package/src/session/lock.ts +19 -0
- package/src/tool/guidance.ts +15 -0
- package/src/types.ts +50 -56
- package/src/ui/choose-renderer.ts +11 -0
- package/src/ui/overlay-actions.ts +42 -0
- package/src/ui/overlay-render.ts +196 -0
- package/src/ui/overlay-view.ts +216 -0
- package/src/ui/overlay.ts +388 -0
- package/src/ui/types.ts +35 -0
- package/src/flow.ts +0 -224
- package/src/format.ts +0 -66
- package/src/render/ui-rich-render-editor.ts +0 -51
- package/src/render/ui-rich-render-env.ts +0 -15
- package/src/render/ui-rich-render-footer.ts +0 -55
- package/src/render/ui-rich-render-markdown.ts +0 -33
- package/src/render/ui-rich-render-notes.ts +0 -80
- package/src/render/ui-rich-render-types.ts +0 -17
- package/src/render/ui-rich-render.ts +0 -323
- package/src/render.ts +0 -95
- package/src/result.ts +0 -90
- package/src/ui/ui-rich-handlers.ts +0 -369
- package/src/ui/ui-rich-inline.ts +0 -77
- package/src/ui/ui-rich-state.ts +0 -179
- package/src/ui/ui-rich.ts +0 -144
- /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
Answer,
|
|
4
|
+
AskUserDetails,
|
|
5
|
+
AskUserErrorDetails,
|
|
6
|
+
AskUserOutcome,
|
|
7
|
+
AskUserToolDetails,
|
|
8
|
+
NormalizedQuestion,
|
|
9
|
+
NormalizedQuestionnaire,
|
|
10
|
+
} from "../types.ts";
|
|
11
|
+
|
|
12
|
+
export type AskUserToolResult = AgentToolResult<AskUserToolDetails>;
|
|
13
|
+
|
|
14
|
+
export function buildResult(
|
|
15
|
+
questionnaire: NormalizedQuestionnaire,
|
|
16
|
+
outcome: AskUserOutcome,
|
|
17
|
+
): AskUserToolResult {
|
|
18
|
+
const details: AskUserDetails = {
|
|
19
|
+
...(questionnaire.title ? { title: questionnaire.title } : {}),
|
|
20
|
+
...(questionnaire.intro ? { intro: questionnaire.intro } : {}),
|
|
21
|
+
questions: questionnaire.questions,
|
|
22
|
+
status: outcome.status,
|
|
23
|
+
answersById: outcome.answersById,
|
|
24
|
+
missingQuestionIds: outcome.missingQuestionIds,
|
|
25
|
+
...(outcome.discussMessage ? { discussMessage: outcome.discussMessage } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: summarizeOutcome(questionnaire.questions, outcome) }],
|
|
30
|
+
details,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildErrorResult(message: string): AskUserToolResult {
|
|
35
|
+
const details: AskUserErrorDetails = { kind: "error", message };
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: message }],
|
|
38
|
+
details,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatAnswerSummary(_question: NormalizedQuestion, answer: Answer): string {
|
|
43
|
+
switch (answer.kind) {
|
|
44
|
+
case "choice":
|
|
45
|
+
return answer.selections.map((selection) => selection.label).join("; ");
|
|
46
|
+
case "custom":
|
|
47
|
+
return `Other — ${answer.value}`;
|
|
48
|
+
case "text":
|
|
49
|
+
return answer.value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function summarizeOutcome(questions: NormalizedQuestion[], outcome: AskUserOutcome): string {
|
|
54
|
+
if (outcome.status === "cancelled") return "User cancelled the form.";
|
|
55
|
+
if (outcome.status === "aborted") return "The form was aborted before completion.";
|
|
56
|
+
|
|
57
|
+
const lines = formatAnsweredLines(questions, outcome.answersById);
|
|
58
|
+
const missing = formatMissingHeaders(questions, outcome.missingQuestionIds);
|
|
59
|
+
|
|
60
|
+
if (outcome.status === "submitted") {
|
|
61
|
+
return lines.length > 0 ? lines.join("\n") : "User submitted the form.";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (outcome.status === "partial") {
|
|
65
|
+
return [
|
|
66
|
+
"User submitted a partial form.",
|
|
67
|
+
...lines,
|
|
68
|
+
...(missing ? [`Missing required answers: ${missing}`] : []),
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
"User wants to discuss before deciding.",
|
|
74
|
+
...(outcome.discussMessage ? [`Discussion request: ${outcome.discussMessage}`] : []),
|
|
75
|
+
...lines,
|
|
76
|
+
...(missing ? [`Still missing: ${missing}`] : []),
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatAnsweredLines(
|
|
81
|
+
questions: NormalizedQuestion[],
|
|
82
|
+
answersById: Record<string, Answer>,
|
|
83
|
+
): string[] {
|
|
84
|
+
return questions.flatMap((question) => {
|
|
85
|
+
const answer = answersById[question.id];
|
|
86
|
+
return answer ? [`${question.header}: ${formatAnswerSummary(question, answer)}`] : [];
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatMissingHeaders(
|
|
91
|
+
questions: NormalizedQuestion[],
|
|
92
|
+
missingQuestionIds: string[],
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const headers = questions
|
|
95
|
+
.filter((question) => missingQuestionIds.includes(question.id))
|
|
96
|
+
.map((question) => question.header);
|
|
97
|
+
return headers.length > 0 ? headers.join(", ") : undefined;
|
|
98
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskUserParams } from "../schema.ts";
|
|
4
|
+
import type { AskUserDetails, AskUserToolDetails } from "../types.ts";
|
|
5
|
+
import { isErrorDetails } from "../types.ts";
|
|
6
|
+
import { formatAnswerSummary, formatMissingHeaders } from "./result.ts";
|
|
7
|
+
|
|
8
|
+
export function renderAskUserCall(args: AskUserParams, theme: Theme): Text {
|
|
9
|
+
const title = args.title?.trim();
|
|
10
|
+
const headers = args.questions.map((question) => question.header.trim()).filter(Boolean);
|
|
11
|
+
const label = title || `${headers.length} question${headers.length === 1 ? "" : "s"}`;
|
|
12
|
+
const suffix = title && headers.length > 0 ? ` (${headers.join(", ")})` : headers.join(", ");
|
|
13
|
+
const text = `${theme.fg("toolTitle", theme.bold("ask_user "))}${theme.fg("muted", label)}${suffix ? theme.fg("dim", ` ${suffix}`) : ""}`;
|
|
14
|
+
return new Text(text, 0, 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderAskUserResult(
|
|
18
|
+
result: Pick<AgentToolResult<AskUserToolDetails>, "content" | "details">,
|
|
19
|
+
theme: Theme,
|
|
20
|
+
): Text {
|
|
21
|
+
if (isErrorDetails(result.details)) {
|
|
22
|
+
return new Text(theme.fg("error", result.details.message), 0, 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines = buildResultLines(result.details, theme);
|
|
26
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildResultLines(details: AskUserDetails, theme: Theme): string[] {
|
|
30
|
+
const answersById = details.answersById;
|
|
31
|
+
const answerLines = details.questions.flatMap((question) => {
|
|
32
|
+
const answer = answersById[question.id];
|
|
33
|
+
return answer
|
|
34
|
+
? [
|
|
35
|
+
`${theme.fg("success", "✓ ")}${theme.fg("accent", question.header)}: ${theme.fg("text", formatAnswerSummary(question, answer))}`,
|
|
36
|
+
]
|
|
37
|
+
: [];
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
switch (details.status) {
|
|
41
|
+
case "submitted":
|
|
42
|
+
return answerLines.length > 0 ? answerLines : [theme.fg("success", "Submitted")];
|
|
43
|
+
case "partial": {
|
|
44
|
+
const missing = formatMissingHeaders(details.questions, details.missingQuestionIds);
|
|
45
|
+
return [
|
|
46
|
+
theme.fg("warning", "Partial"),
|
|
47
|
+
...answerLines,
|
|
48
|
+
...(missing ? [theme.fg("dim", `Missing required: ${missing}`)] : []),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
case "discuss": {
|
|
52
|
+
const missing = formatMissingHeaders(details.questions, details.missingQuestionIds);
|
|
53
|
+
return [
|
|
54
|
+
theme.fg("warning", "Discuss"),
|
|
55
|
+
...(details.discussMessage ? [theme.fg("text", `Message: ${details.discussMessage}`)] : []),
|
|
56
|
+
...answerLines,
|
|
57
|
+
...(missing ? [theme.fg("dim", `Still missing: ${missing}`)] : []),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
case "cancelled":
|
|
61
|
+
return [theme.fg("warning", "Cancelled")];
|
|
62
|
+
case "aborted":
|
|
63
|
+
return [theme.fg("error", "Aborted")];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NormalizedQuestionnaire } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export function buildTreeSummaryLabel(
|
|
4
|
+
questionnaire: Pick<NormalizedQuestionnaire, "title" | "questions">,
|
|
5
|
+
): string {
|
|
6
|
+
const base =
|
|
7
|
+
questionnaire.title?.trim() || questionnaire.questions.map((q) => q.header).join(", ");
|
|
8
|
+
const trimmed = base.length > 70 ? `${base.slice(0, 67)}...` : base;
|
|
9
|
+
return `ask_user · ${trimmed}`;
|
|
10
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -1,100 +1,103 @@
|
|
|
1
|
-
// External
|
|
2
|
-
// Two question types: choice (structured pick-one-or-many with options)
|
|
3
|
-
// and text (freeform input). Yes/no and multichoice have been unified
|
|
4
|
-
// into choice — yes/no is just choice with two options, multi-select
|
|
5
|
-
// is choice with `multi: true`.
|
|
1
|
+
// External, model-facing parameter schema for the redesigned ask_user tool.
|
|
6
2
|
|
|
7
3
|
import { type Static, Type } from "typebox";
|
|
8
4
|
|
|
9
|
-
const
|
|
10
|
-
value: Type.String({ description: "Stable identifier returned
|
|
5
|
+
const OptionSchema = Type.Object({
|
|
6
|
+
value: Type.String({ description: "Stable identifier returned when this option is selected" }),
|
|
11
7
|
label: Type.String({ description: "Display label shown to the user" }),
|
|
12
8
|
description: Type.Optional(
|
|
13
9
|
Type.String({
|
|
14
|
-
description:
|
|
15
|
-
"Optional clarification shown under the label (wraps naturally, a short paragraph is fine)",
|
|
10
|
+
description: "Optional clarification shown under the label in richer UIs",
|
|
16
11
|
}),
|
|
17
12
|
),
|
|
18
13
|
preview: Type.Optional(
|
|
19
14
|
Type.String({
|
|
20
|
-
description:
|
|
21
|
-
"Optional rich preview content shown in the TUI (markdown, code, or ASCII mockups)",
|
|
15
|
+
description: "Optional preview content shown for the currently focused option",
|
|
22
16
|
}),
|
|
23
17
|
),
|
|
24
18
|
});
|
|
25
19
|
|
|
26
20
|
const ChoiceQuestionSchema = Type.Object({
|
|
27
21
|
type: Type.Literal("choice"),
|
|
28
|
-
id: Type.String({ description: "Unique question id within this
|
|
29
|
-
header: Type.String({ description: "Short label
|
|
22
|
+
id: Type.String({ description: "Unique question id within this form" }),
|
|
23
|
+
header: Type.String({ description: "Short label describing the decision" }),
|
|
30
24
|
prompt: Type.String({ description: "Full question text shown to the user" }),
|
|
31
|
-
options: Type.Array(
|
|
32
|
-
description: "Allowed answers (2-12
|
|
25
|
+
options: Type.Array(OptionSchema, {
|
|
26
|
+
description: "Allowed answers (2-12 distinct options)",
|
|
33
27
|
}),
|
|
34
28
|
required: Type.Optional(
|
|
35
29
|
Type.Boolean({
|
|
36
30
|
default: true,
|
|
37
|
-
description: "Whether this question must be answered
|
|
38
|
-
}),
|
|
39
|
-
),
|
|
40
|
-
allowOther: Type.Optional(
|
|
41
|
-
Type.Boolean({
|
|
42
|
-
description: "Allow an explicit custom answer path instead of forcing one of the options",
|
|
31
|
+
description: "Whether this question must be answered for a full submit",
|
|
43
32
|
}),
|
|
44
33
|
),
|
|
45
|
-
|
|
34
|
+
multi: Type.Optional(
|
|
46
35
|
Type.Boolean({
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
default: false,
|
|
37
|
+
description: "Allow selecting multiple options instead of one",
|
|
49
38
|
}),
|
|
50
39
|
),
|
|
51
|
-
|
|
40
|
+
allowOther: Type.Optional(
|
|
52
41
|
Type.Boolean({
|
|
53
|
-
default: false,
|
|
54
42
|
description:
|
|
55
|
-
"Allow
|
|
43
|
+
"Allow a custom freeform answer instead of the listed options. Only valid for single-select choice questions.",
|
|
56
44
|
}),
|
|
57
45
|
),
|
|
58
46
|
recommendation: Type.Optional(
|
|
59
47
|
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
60
48
|
description:
|
|
61
|
-
"Recommended option value
|
|
49
|
+
"Recommended option value or values. Use a string for single-select and an array for multi-select.",
|
|
62
50
|
}),
|
|
63
51
|
),
|
|
64
|
-
|
|
52
|
+
initial: Type.Optional(
|
|
65
53
|
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
66
54
|
description:
|
|
67
|
-
"
|
|
55
|
+
"Initial selected option value or values. Use a string for single-select and an array for multi-select.",
|
|
68
56
|
}),
|
|
69
57
|
),
|
|
70
58
|
});
|
|
71
59
|
|
|
72
60
|
const TextQuestionSchema = Type.Object({
|
|
73
61
|
type: Type.Literal("text"),
|
|
74
|
-
id: Type.String({ description: "Unique question id within this
|
|
75
|
-
header: Type.String({ description: "Short label
|
|
62
|
+
id: Type.String({ description: "Unique question id within this form" }),
|
|
63
|
+
header: Type.String({ description: "Short label describing the prompt" }),
|
|
76
64
|
prompt: Type.String({ description: "Full question text shown to the user" }),
|
|
77
65
|
required: Type.Optional(
|
|
78
66
|
Type.Boolean({
|
|
79
67
|
default: true,
|
|
80
|
-
description: "Whether this question must be answered
|
|
68
|
+
description: "Whether this question must be answered for a full submit",
|
|
81
69
|
}),
|
|
82
70
|
),
|
|
83
|
-
|
|
84
|
-
|
|
71
|
+
initial: Type.Optional(Type.String({ description: "Initial value shown in the editor" })),
|
|
72
|
+
placeholder: Type.Optional(
|
|
73
|
+
Type.String({ description: "Placeholder shown before the user types" }),
|
|
85
74
|
),
|
|
86
75
|
});
|
|
87
76
|
|
|
88
77
|
const QuestionSchema = Type.Union([ChoiceQuestionSchema, TextQuestionSchema]);
|
|
89
78
|
|
|
90
79
|
export const AskUserParamsSchema = Type.Object({
|
|
80
|
+
title: Type.Optional(
|
|
81
|
+
Type.String({ description: "Optional short title explaining the overall decision" }),
|
|
82
|
+
),
|
|
83
|
+
intro: Type.Optional(
|
|
84
|
+
Type.String({
|
|
85
|
+
description: "Optional introductory context explaining why the agent is asking",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
91
88
|
questions: Type.Array(QuestionSchema, {
|
|
92
|
-
description: "Between 1 and 4 focused decision
|
|
89
|
+
description: "Between 1 and 4 focused questions that belong to the same decision",
|
|
93
90
|
}),
|
|
94
|
-
|
|
91
|
+
allowPartialSubmit: Type.Optional(
|
|
92
|
+
Type.Boolean({
|
|
93
|
+
description:
|
|
94
|
+
"Allow the user to submit a partial form when some required questions remain unanswered",
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
allowDiscuss: Type.Optional(
|
|
95
98
|
Type.Boolean({
|
|
96
99
|
description:
|
|
97
|
-
"
|
|
100
|
+
"Allow the user to switch back into discussion instead of committing to a final answer",
|
|
98
101
|
}),
|
|
99
102
|
),
|
|
100
103
|
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Answer,
|
|
3
|
+
AskUserOutcome,
|
|
4
|
+
AskUserStatus,
|
|
5
|
+
NormalizedChoiceQuestion,
|
|
6
|
+
NormalizedQuestion,
|
|
7
|
+
NormalizedQuestionnaire,
|
|
8
|
+
} from "../types.ts";
|
|
9
|
+
|
|
10
|
+
export class AskUserController {
|
|
11
|
+
private readonly answers = new Map<string, Answer>();
|
|
12
|
+
private index = 0;
|
|
13
|
+
private status: AskUserStatus | null = null;
|
|
14
|
+
private discussMessage: string | undefined;
|
|
15
|
+
|
|
16
|
+
constructor(public readonly questionnaire: NormalizedQuestionnaire) {
|
|
17
|
+
if (questionnaire.questions.length === 0) {
|
|
18
|
+
throw new Error("AskUserController requires at least one question.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get questions(): NormalizedQuestion[] {
|
|
23
|
+
return this.questionnaire.questions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get currentIndex(): number {
|
|
27
|
+
return this.index;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get currentQuestion(): NormalizedQuestion {
|
|
31
|
+
const question = this.questionnaire.questions[this.index];
|
|
32
|
+
if (!question) {
|
|
33
|
+
throw new Error(`No question at index ${this.index}.`);
|
|
34
|
+
}
|
|
35
|
+
return question;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get isTerminal(): boolean {
|
|
39
|
+
return this.status !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get answerCount(): number {
|
|
43
|
+
return this.answers.size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
hasAnswer(questionId: string): boolean {
|
|
47
|
+
return this.answers.has(questionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAnswer(questionId: string): Answer | undefined {
|
|
51
|
+
return this.answers.get(questionId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getSelectedIndexes(question: NormalizedChoiceQuestion): number[] {
|
|
55
|
+
const answer = this.answers.get(question.id);
|
|
56
|
+
if (answer?.kind !== "choice") return [...question.initialIndexes];
|
|
57
|
+
return answer.selections
|
|
58
|
+
.map((selection) => question.options.findIndex((option) => option.value === selection.value))
|
|
59
|
+
.filter((index) => index >= 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setAnswer(questionId: string, answer: Answer): void {
|
|
63
|
+
if (this.isTerminal) return;
|
|
64
|
+
this.answers.set(questionId, normalizeAnswer(answer));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
clearAnswer(questionId: string): void {
|
|
68
|
+
if (this.isTerminal) return;
|
|
69
|
+
this.answers.delete(questionId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
goNext(): boolean {
|
|
73
|
+
if (this.isTerminal) return false;
|
|
74
|
+
if (this.index >= this.questionnaire.questions.length - 1) return false;
|
|
75
|
+
this.index += 1;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
goBack(): boolean {
|
|
80
|
+
if (this.isTerminal) return false;
|
|
81
|
+
if (this.index === 0) return false;
|
|
82
|
+
this.index -= 1;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
canSubmit(): boolean {
|
|
87
|
+
return this.missingQuestionIds().length === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
canPartialSubmit(): boolean {
|
|
91
|
+
return (
|
|
92
|
+
this.questionnaire.allowPartialSubmit &&
|
|
93
|
+
this.answerCount > 0 &&
|
|
94
|
+
this.missingQuestionIds().length > 0
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
finishSubmitted(): boolean {
|
|
99
|
+
if (!this.canSubmit() || this.isTerminal) return false;
|
|
100
|
+
this.status = "submitted";
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
finishPartial(): boolean {
|
|
105
|
+
if (!this.canPartialSubmit() || this.isTerminal) return false;
|
|
106
|
+
this.status = "partial";
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
finishDiscuss(message?: string): boolean {
|
|
111
|
+
if (!this.questionnaire.allowDiscuss || this.isTerminal) return false;
|
|
112
|
+
this.status = "discuss";
|
|
113
|
+
this.discussMessage = trimOptional(message);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cancel(): void {
|
|
118
|
+
if (this.isTerminal) return;
|
|
119
|
+
this.status = "cancelled";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
abort(): void {
|
|
123
|
+
if (this.isTerminal) return;
|
|
124
|
+
this.status = "aborted";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
outcome(): AskUserOutcome {
|
|
128
|
+
return {
|
|
129
|
+
status: this.status ?? "cancelled",
|
|
130
|
+
answersById: Object.fromEntries(this.answers),
|
|
131
|
+
missingQuestionIds: this.missingQuestionIds(),
|
|
132
|
+
...(this.discussMessage ? { discussMessage: this.discussMessage } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
missingQuestionIds(): string[] {
|
|
137
|
+
return this.questionnaire.questions
|
|
138
|
+
.filter((question) => question.required && !this.answers.has(question.id))
|
|
139
|
+
.map((question) => question.id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeAnswer(answer: Answer): Answer {
|
|
144
|
+
switch (answer.kind) {
|
|
145
|
+
case "choice":
|
|
146
|
+
return {
|
|
147
|
+
kind: "choice",
|
|
148
|
+
selections: answer.selections.map((selection) => ({
|
|
149
|
+
value: selection.value.trim(),
|
|
150
|
+
label: selection.label.trim(),
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
case "custom":
|
|
154
|
+
return { kind: "custom", value: answer.value.trim() };
|
|
155
|
+
case "text":
|
|
156
|
+
return { kind: "text", value: answer.value.trim() };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function trimOptional(value: string | undefined): string | undefined {
|
|
161
|
+
const trimmed = value?.trim();
|
|
162
|
+
return trimmed ? trimmed : undefined;
|
|
163
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Session-scoped single-active interaction guard for ask_user.
|
|
2
|
+
|
|
3
|
+
export class ActiveQuestionnaireLock {
|
|
4
|
+
private active = false;
|
|
5
|
+
|
|
6
|
+
acquire(): boolean {
|
|
7
|
+
if (this.active) return false;
|
|
8
|
+
this.active = true;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
release(): void {
|
|
13
|
+
this.active = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
isActive(): boolean {
|
|
17
|
+
return this.active;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Prompt guidance and tool description for the redesigned ask_user tool.
|
|
2
|
+
|
|
3
|
+
export const toolDescription =
|
|
4
|
+
"Ask the user for a focused blocking decision when explicit input is required to proceed safely. Requires an interactive UI with custom overlay support, and only one ask_user form can be active at a time. Use 1-4 related `choice` or `text` questions. Do not use ask_user for open-ended interviews or repo facts you can get yourself. Forms may allow partial submit or discussion handoff.";
|
|
5
|
+
|
|
6
|
+
export const promptSnippet = "ask_user — request a focused blocking user decision";
|
|
7
|
+
|
|
8
|
+
export const promptGuidelines = [
|
|
9
|
+
"Use ask_user only when explicit user input is required to proceed safely; do not use ask_user for open-ended interviews or repo facts.",
|
|
10
|
+
"Use ask_user with 1-4 related questions; prefer one when possible.",
|
|
11
|
+
'Use ask_user `choice` for fixed options and ask_user `text` for freeform input; model yes/no as `choice` with `{ value: "yes", label: "Yes" }` and `{ value: "no", label: "No" }`.',
|
|
12
|
+
"Use ask_user `allowOther` only on single-select `choice` questions.",
|
|
13
|
+
"Use ask_user `allowDiscuss` or `allowPartialSubmit` only when that outcome is actionable.",
|
|
14
|
+
"Do not call ask_user while another ask_user form is already in flight.",
|
|
15
|
+
];
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// The external
|
|
3
|
-
//
|
|
1
|
+
// Shared internal and public data model for the redesigned ask_user tool.
|
|
2
|
+
// The external tool-call schema lives in schema.ts; everything past validation
|
|
3
|
+
// works with the normalized shapes defined here.
|
|
4
4
|
|
|
5
|
-
export type
|
|
6
|
-
|
|
7
|
-
export type TerminalState = "submitted" | "cancelled" | "aborted" | "skipped";
|
|
5
|
+
export type AskUserStatus = "submitted" | "partial" | "discuss" | "cancelled" | "aborted";
|
|
8
6
|
|
|
9
7
|
export interface NormalizedOption {
|
|
10
8
|
value: string;
|
|
@@ -17,102 +15,98 @@ interface BaseQuestion {
|
|
|
17
15
|
id: string;
|
|
18
16
|
header: string;
|
|
19
17
|
prompt: string;
|
|
20
|
-
type: QuestionType;
|
|
21
18
|
required: boolean;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
interface
|
|
21
|
+
export interface NormalizedChoiceQuestion extends BaseQuestion {
|
|
22
|
+
type: "choice";
|
|
25
23
|
options: NormalizedOption[];
|
|
24
|
+
multi: boolean;
|
|
26
25
|
allowOther: boolean;
|
|
27
|
-
allowDiscuss: boolean;
|
|
28
26
|
recommendedIndexes: number[];
|
|
29
|
-
|
|
30
|
-
multi: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface NormalizedChoiceQuestion extends StructuredQuestionBase {
|
|
34
|
-
type: "choice";
|
|
27
|
+
initialIndexes: number[];
|
|
35
28
|
}
|
|
36
29
|
|
|
37
30
|
export interface NormalizedTextQuestion extends BaseQuestion {
|
|
38
31
|
type: "text";
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
initial?: string;
|
|
33
|
+
placeholder?: string;
|
|
41
34
|
}
|
|
42
35
|
|
|
43
|
-
export type NormalizedStructuredQuestion = NormalizedChoiceQuestion;
|
|
44
|
-
|
|
45
36
|
export type NormalizedQuestion = NormalizedChoiceQuestion | NormalizedTextQuestion;
|
|
46
37
|
|
|
47
38
|
export interface NormalizedQuestionnaire {
|
|
39
|
+
title?: string;
|
|
40
|
+
intro?: string;
|
|
48
41
|
questions: NormalizedQuestion[];
|
|
49
|
-
|
|
42
|
+
allowPartialSubmit: boolean;
|
|
43
|
+
allowDiscuss: boolean;
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
export interface
|
|
46
|
+
export interface AnswerSelection {
|
|
53
47
|
value: string;
|
|
54
|
-
|
|
55
|
-
note?: string;
|
|
48
|
+
label: string;
|
|
56
49
|
}
|
|
57
50
|
|
|
58
51
|
export interface ChoiceAnswer {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
selections: Selection[];
|
|
52
|
+
kind: "choice";
|
|
53
|
+
selections: AnswerSelection[];
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
export interface
|
|
65
|
-
|
|
66
|
-
source: "other";
|
|
56
|
+
export interface CustomAnswer {
|
|
57
|
+
kind: "custom";
|
|
67
58
|
value: string;
|
|
68
59
|
}
|
|
69
60
|
|
|
70
|
-
export interface DiscussAnswer {
|
|
71
|
-
questionId: string;
|
|
72
|
-
source: "discuss";
|
|
73
|
-
value?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
61
|
export interface TextAnswer {
|
|
77
|
-
|
|
78
|
-
source: "text";
|
|
62
|
+
kind: "text";
|
|
79
63
|
value: string;
|
|
80
64
|
}
|
|
81
65
|
|
|
82
|
-
export type Answer = ChoiceAnswer |
|
|
66
|
+
export type Answer = ChoiceAnswer | CustomAnswer | TextAnswer;
|
|
83
67
|
|
|
84
|
-
export interface
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
68
|
+
export interface AskUserOutcome {
|
|
69
|
+
status: AskUserStatus;
|
|
70
|
+
answersById: Record<string, Answer>;
|
|
71
|
+
missingQuestionIds: string[];
|
|
72
|
+
discussMessage?: string;
|
|
88
73
|
}
|
|
89
74
|
|
|
90
|
-
export interface AskUserDetails {
|
|
75
|
+
export interface AskUserDetails extends AskUserOutcome {
|
|
76
|
+
title?: string;
|
|
77
|
+
intro?: string;
|
|
91
78
|
questions: NormalizedQuestion[];
|
|
92
|
-
answers: Answer[];
|
|
93
|
-
answersById: Record<string, Answer | undefined>;
|
|
94
|
-
terminalState: TerminalState;
|
|
95
79
|
}
|
|
96
80
|
|
|
97
|
-
export
|
|
81
|
+
export interface AskUserErrorDetails {
|
|
82
|
+
kind: "error";
|
|
83
|
+
message: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type AskUserToolDetails = AskUserDetails | AskUserErrorDetails;
|
|
87
|
+
|
|
88
|
+
export const ASK_USER_LIMITS = {
|
|
98
89
|
minQuestions: 1,
|
|
99
90
|
maxQuestions: 4,
|
|
100
|
-
maxHeaderLength: 60,
|
|
101
|
-
maxPromptLength: 4000,
|
|
102
91
|
minChoiceOptions: 2,
|
|
103
92
|
maxChoiceOptions: 12,
|
|
93
|
+
maxHeaderLength: 60,
|
|
94
|
+
maxPromptLength: 4000,
|
|
95
|
+
maxTitleLength: 120,
|
|
96
|
+
maxIntroLength: 4000,
|
|
97
|
+
maxPlaceholderLength: 200,
|
|
104
98
|
} as const;
|
|
105
99
|
|
|
106
|
-
export function
|
|
100
|
+
export function isChoiceQuestion(
|
|
107
101
|
question: NormalizedQuestion,
|
|
108
|
-
): question is
|
|
109
|
-
return question.type
|
|
102
|
+
): question is NormalizedChoiceQuestion {
|
|
103
|
+
return question.type === "choice";
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
export function
|
|
113
|
-
return
|
|
106
|
+
export function isTextQuestion(question: NormalizedQuestion): question is NormalizedTextQuestion {
|
|
107
|
+
return question.type === "text";
|
|
114
108
|
}
|
|
115
109
|
|
|
116
|
-
export function
|
|
117
|
-
return
|
|
110
|
+
export function isErrorDetails(details: AskUserToolDetails): details is AskUserErrorDetails {
|
|
111
|
+
return "kind" in details && details.kind === "error";
|
|
118
112
|
}
|