pi-mono-all 1.0.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/CHANGELOG.md +13 -0
- package/LICENCE.md +7 -0
- package/node_modules/pi-common/package.json +22 -0
- package/node_modules/pi-common/src/auth-config.ts +290 -0
- package/node_modules/pi-common/src/auth.ts +63 -0
- package/node_modules/pi-common/src/cache.ts +60 -0
- package/node_modules/pi-common/src/errors.ts +47 -0
- package/node_modules/pi-common/src/http-client.ts +118 -0
- package/node_modules/pi-common/src/index.ts +7 -0
- package/node_modules/pi-common/src/rate-limiter.ts +32 -0
- package/node_modules/pi-common/src/tool-result.ts +27 -0
- package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
- package/node_modules/pi-mono-ask-user-question/README.md +226 -0
- package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
- package/node_modules/pi-mono-ask-user-question/package.json +29 -0
- package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-auto-fix/README.md +77 -0
- package/node_modules/pi-mono-auto-fix/index.ts +488 -0
- package/node_modules/pi-mono-auto-fix/package.json +23 -0
- package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-btw/README.md +24 -0
- package/node_modules/pi-mono-btw/index.ts +499 -0
- package/node_modules/pi-mono-btw/package.json +29 -0
- package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-clear/README.md +40 -0
- package/node_modules/pi-mono-clear/index.ts +45 -0
- package/node_modules/pi-mono-clear/package.json +29 -0
- package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
- package/node_modules/pi-mono-context/README.md +74 -0
- package/node_modules/pi-mono-context/index.ts +641 -0
- package/node_modules/pi-mono-context/package.json +29 -0
- package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
- package/node_modules/pi-mono-context-guard/README.md +81 -0
- package/node_modules/pi-mono-context-guard/index.ts +212 -0
- package/node_modules/pi-mono-context-guard/package.json +23 -0
- package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-figma/README.md +236 -0
- package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
- package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
- package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
- package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
- package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
- package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
- package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
- package/node_modules/pi-mono-figma/index.ts +6 -0
- package/node_modules/pi-mono-figma/package.json +33 -0
- package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
- package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
- package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
- package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
- package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
- package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
- package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
- package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
- package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
- package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
- package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
- package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
- package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
- package/node_modules/pi-mono-linear/README.md +159 -0
- package/node_modules/pi-mono-linear/index.ts +6 -0
- package/node_modules/pi-mono-linear/package.json +30 -0
- package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
- package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
- package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
- package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
- package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
- package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-loop/README.md +54 -0
- package/node_modules/pi-mono-loop/index.ts +291 -0
- package/node_modules/pi-mono-loop/package.json +26 -0
- package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
- package/node_modules/pi-mono-multi-edit/README.md +244 -0
- package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
- package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
- package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
- package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
- package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
- package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
- package/node_modules/pi-mono-multi-edit/index.ts +266 -0
- package/node_modules/pi-mono-multi-edit/package.json +37 -0
- package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
- package/node_modules/pi-mono-multi-edit/types.ts +53 -0
- package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
- package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
- package/node_modules/pi-mono-review/README.md +30 -0
- package/node_modules/pi-mono-review/common.ts +930 -0
- package/node_modules/pi-mono-review/index.ts +8 -0
- package/node_modules/pi-mono-review/package.json +29 -0
- package/node_modules/pi-mono-review/review-tui.ts +194 -0
- package/node_modules/pi-mono-review/review.ts +119 -0
- package/node_modules/pi-mono-review/reviewer.ts +339 -0
- package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
- package/node_modules/pi-mono-sentinel/README.md +87 -0
- package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
- package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
- package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
- package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
- package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
- package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
- package/node_modules/pi-mono-sentinel/index.ts +43 -0
- package/node_modules/pi-mono-sentinel/package.json +26 -0
- package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
- package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
- package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
- package/node_modules/pi-mono-sentinel/session.ts +95 -0
- package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
- package/node_modules/pi-mono-sentinel/types.ts +39 -0
- package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
- package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-simplify/README.md +56 -0
- package/node_modules/pi-mono-simplify/index.ts +78 -0
- package/node_modules/pi-mono-simplify/package.json +29 -0
- package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-status-line/README.md +96 -0
- package/node_modules/pi-mono-status-line/basic.ts +89 -0
- package/node_modules/pi-mono-status-line/expert.ts +689 -0
- package/node_modules/pi-mono-status-line/index.ts +54 -0
- package/node_modules/pi-mono-status-line/package.json +29 -0
- package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
- package/node_modules/pi-mono-team-mode/README.md +246 -0
- package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
- package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
- package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
- package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
- package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
- package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
- package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
- package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
- package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
- package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
- package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
- package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
- package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
- package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
- package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
- package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
- package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
- package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
- package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
- package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
- package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
- package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
- package/node_modules/pi-mono-team-mode/index.ts +825 -0
- package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
- package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
- package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
- package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
- package/node_modules/pi-mono-team-mode/package.json +33 -0
- package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
- package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
- package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
- package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
- package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
- package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
- package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
- package/package.json +76 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask-user-question — Interactive form tool for pi
|
|
3
|
+
*
|
|
4
|
+
* A powerful tool that the LLM can call to ask the user one or more questions
|
|
5
|
+
* using rich form controls: radio buttons, checkboxes, and text inputs.
|
|
6
|
+
* Each question type supports an optional "Other..." escape hatch for custom input.
|
|
7
|
+
*
|
|
8
|
+
* Question types:
|
|
9
|
+
* - radio: Single-select from options (with optional custom "Other")
|
|
10
|
+
* - checkbox: Multi-select from options (with optional custom "Other")
|
|
11
|
+
* - text: Free-form text input
|
|
12
|
+
*
|
|
13
|
+
* Navigation:
|
|
14
|
+
* - Tab / Shift+Tab to move between questions
|
|
15
|
+
* - Up/Down to navigate options within a question
|
|
16
|
+
* - Space to toggle checkboxes
|
|
17
|
+
* - Enter to select radio / submit text / advance
|
|
18
|
+
* - Esc to cancel
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
22
|
+
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
23
|
+
import { Type } from "@sinclair/typebox";
|
|
24
|
+
|
|
25
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface QuestionOption {
|
|
28
|
+
value: string;
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Question {
|
|
34
|
+
id: string;
|
|
35
|
+
type: "radio" | "checkbox" | "text";
|
|
36
|
+
prompt: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
options?: QuestionOption[];
|
|
39
|
+
allowOther?: boolean;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
default?: string | string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface NormalizedQuestion extends Question {
|
|
46
|
+
label: string;
|
|
47
|
+
options: QuestionOption[];
|
|
48
|
+
allowOther: boolean;
|
|
49
|
+
required: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Answer {
|
|
53
|
+
id: string;
|
|
54
|
+
type: "radio" | "checkbox" | "text";
|
|
55
|
+
value: string | string[];
|
|
56
|
+
wasCustom: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface FormResult {
|
|
60
|
+
title?: string;
|
|
61
|
+
questions: NormalizedQuestion[];
|
|
62
|
+
answers: Answer[];
|
|
63
|
+
cancelled: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Schema ──────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const OptionSchema = Type.Object({
|
|
69
|
+
value: Type.String({ description: "Value returned when selected" }),
|
|
70
|
+
label: Type.String({ description: "Display label" }),
|
|
71
|
+
description: Type.Optional(Type.String({ description: "Help text shown below the label" })),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const QuestionSchema = Type.Object({
|
|
75
|
+
id: Type.String({ description: "Unique identifier for this question" }),
|
|
76
|
+
type: Type.Unsafe<"radio" | "checkbox" | "text">({
|
|
77
|
+
type: "string",
|
|
78
|
+
enum: ["radio", "checkbox", "text"],
|
|
79
|
+
description: "Question type: radio (single-select), checkbox (multi-select), or text (free input)",
|
|
80
|
+
}),
|
|
81
|
+
prompt: Type.String({ description: "The question text to display" }),
|
|
82
|
+
label: Type.Optional(Type.String({ description: "Short label for tab bar (defaults to Q1, Q2...)" })),
|
|
83
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: "Options for radio/checkbox types" })),
|
|
84
|
+
allowOther: Type.Optional(
|
|
85
|
+
Type.Boolean({ description: "Add an 'Other...' option with text input (default: true for radio/checkbox)" }),
|
|
86
|
+
),
|
|
87
|
+
required: Type.Optional(Type.Boolean({ description: "Whether an answer is required (default: true)" })),
|
|
88
|
+
placeholder: Type.Optional(Type.String({ description: "Placeholder for text inputs" })),
|
|
89
|
+
default: Type.Optional(
|
|
90
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
91
|
+
description: "Default value(s). String for radio/text, string[] for checkbox",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const AskUserQuestionParams = Type.Object({
|
|
97
|
+
title: Type.Optional(Type.String({ description: "Form title displayed at the top" })),
|
|
98
|
+
description: Type.Optional(Type.String({ description: "Brief context or instructions shown under the title" })),
|
|
99
|
+
questions: Type.Array(QuestionSchema, {
|
|
100
|
+
description: "One or more questions to ask. Use radio for single-select, checkbox for multi-select, text for free input",
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function normalize(questions: Question[]): NormalizedQuestion[] {
|
|
107
|
+
return questions.map((q, i) => ({
|
|
108
|
+
...q,
|
|
109
|
+
label: q.label || `Q${i + 1}`,
|
|
110
|
+
options: q.options || [],
|
|
111
|
+
allowOther: q.type === "text" ? false : q.allowOther !== false,
|
|
112
|
+
required: q.required !== false,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function wrapText(text: string, maxWidth: number): string[] {
|
|
117
|
+
const words = text.split(" ");
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
let current = "";
|
|
120
|
+
for (const word of words) {
|
|
121
|
+
if (!current) {
|
|
122
|
+
current = word;
|
|
123
|
+
} else if (current.length + 1 + word.length <= maxWidth) {
|
|
124
|
+
current += ` ${word}`;
|
|
125
|
+
} else {
|
|
126
|
+
lines.push(current);
|
|
127
|
+
current = word;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (current) lines.push(current);
|
|
131
|
+
return lines.length ? lines : [""];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function errorResult(msg: string): {
|
|
135
|
+
content: { type: "text"; text: string }[];
|
|
136
|
+
details: FormResult;
|
|
137
|
+
} {
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: "text", text: msg }],
|
|
140
|
+
details: { questions: [], answers: [], cancelled: true },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Symbols ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const SYM = {
|
|
147
|
+
radioOn: "◉",
|
|
148
|
+
radioOff: "○",
|
|
149
|
+
checkOn: "☑",
|
|
150
|
+
checkOff: "☐",
|
|
151
|
+
pointer: "❯",
|
|
152
|
+
dot: "·",
|
|
153
|
+
check: "✓",
|
|
154
|
+
pencil: "✎",
|
|
155
|
+
submit: "✓",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
161
|
+
pi.registerTool({
|
|
162
|
+
name: "ask_user_question",
|
|
163
|
+
label: "Ask User",
|
|
164
|
+
description: `Ask the user one or more questions using an interactive form. Supports three question types:
|
|
165
|
+
- **radio**: Single-select from predefined options (like multiple choice)
|
|
166
|
+
- **checkbox**: Multi-select from options (pick all that apply)
|
|
167
|
+
- **text**: Free-form text input
|
|
168
|
+
|
|
169
|
+
Each radio/checkbox question can include an "Other..." option that lets the user type a custom answer.
|
|
170
|
+
|
|
171
|
+
Use this tool when you need user input to proceed — for clarifying requirements, getting preferences, confirming decisions, or choosing between alternatives. Prefer this over asking plain-text questions in your response.`,
|
|
172
|
+
promptSnippet: "Ask the user interactive questions with radio, checkbox, or text inputs",
|
|
173
|
+
promptGuidelines: [
|
|
174
|
+
"Use ask_user_question instead of asking questions in plain text when you need structured user input.",
|
|
175
|
+
"Prefer radio for single-choice, checkbox for multi-choice, text for open-ended answers.",
|
|
176
|
+
"Always include an 'Other' escape hatch (allowOther: true) unless the options are exhaustive.",
|
|
177
|
+
"Group related questions in a single call rather than making multiple separate calls.",
|
|
178
|
+
],
|
|
179
|
+
parameters: AskUserQuestionParams as any,
|
|
180
|
+
|
|
181
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
182
|
+
if (!ctx.hasUI) {
|
|
183
|
+
return errorResult("Error: UI not available (running in non-interactive mode)");
|
|
184
|
+
}
|
|
185
|
+
if (!params.questions.length) {
|
|
186
|
+
return errorResult("Error: No questions provided");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const questions = normalize(params.questions as Question[]);
|
|
190
|
+
const isMulti = questions.length > 1;
|
|
191
|
+
const totalTabs = questions.length + (isMulti ? 1 : 0); // +1 for Submit tab
|
|
192
|
+
|
|
193
|
+
const result = await ctx.ui.custom<FormResult>((tui, theme, _kb, done) => {
|
|
194
|
+
// ── State ────────────────────────────────────────────────
|
|
195
|
+
let currentTab = 0;
|
|
196
|
+
let cursorIdx = 0; // cursor within current question's options
|
|
197
|
+
let otherMode = false; // typing into "Other..." editor
|
|
198
|
+
let otherQuestionId: string | null = null;
|
|
199
|
+
let cachedLines: string[] | undefined;
|
|
200
|
+
|
|
201
|
+
// Answers store
|
|
202
|
+
const radioAnswers = new Map<string, { value: string; label: string; wasCustom: boolean }>();
|
|
203
|
+
const checkAnswers = new Map<string, Set<string>>(); // id -> set of selected values
|
|
204
|
+
const checkCustom = new Map<string, string>(); // id -> custom "other" text
|
|
205
|
+
const textAnswers = new Map<string, string>();
|
|
206
|
+
|
|
207
|
+
// Initialize defaults
|
|
208
|
+
for (const q of questions) {
|
|
209
|
+
if (q.type === "checkbox") {
|
|
210
|
+
const defaults = new Set<string>();
|
|
211
|
+
if (Array.isArray(q.default)) {
|
|
212
|
+
for (const v of q.default) defaults.add(v);
|
|
213
|
+
}
|
|
214
|
+
checkAnswers.set(q.id, defaults);
|
|
215
|
+
} else if (q.type === "text" && typeof q.default === "string") {
|
|
216
|
+
textAnswers.set(q.id, q.default);
|
|
217
|
+
} else if (q.type === "radio" && typeof q.default === "string") {
|
|
218
|
+
const opt = q.options.find((o) => o.value === q.default);
|
|
219
|
+
if (opt) radioAnswers.set(q.id, { value: opt.value, label: opt.label, wasCustom: false });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Editor for "Other" and "text" fields
|
|
224
|
+
const editorTheme: EditorTheme = {
|
|
225
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
226
|
+
selectList: {
|
|
227
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
228
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
229
|
+
description: (t) => theme.fg("muted", t),
|
|
230
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
231
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const editor = new Editor(tui, editorTheme);
|
|
235
|
+
|
|
236
|
+
function getNextTab(): number {
|
|
237
|
+
if (currentTab < questions.length - 1) {
|
|
238
|
+
return currentTab + 1;
|
|
239
|
+
}
|
|
240
|
+
return questions.length; // Submit tab
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function advanceTab() {
|
|
244
|
+
if (!(questions.length > 1)) {
|
|
245
|
+
finishSubmit(false);
|
|
246
|
+
} else {
|
|
247
|
+
switchTab(getNextTab());
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function refresh() {
|
|
252
|
+
cachedLines = undefined;
|
|
253
|
+
tui.requestRender();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function curQ(): NormalizedQuestion | undefined {
|
|
257
|
+
return questions[currentTab];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Save "Other" editor text to the appropriate answer store and exit otherMode. */
|
|
261
|
+
function saveOtherModeText() {
|
|
262
|
+
if (!otherMode || !otherQuestionId) return;
|
|
263
|
+
const t = editor.getText().trim();
|
|
264
|
+
const oq = questions.find((q) => q.id === otherQuestionId);
|
|
265
|
+
if (oq?.type === "radio" && t) {
|
|
266
|
+
radioAnswers.set(oq.id, { value: t, label: t, wasCustom: true });
|
|
267
|
+
} else if (oq?.type === "checkbox" && t) {
|
|
268
|
+
checkCustom.set(oq.id, t);
|
|
269
|
+
}
|
|
270
|
+
otherMode = false;
|
|
271
|
+
otherQuestionId = null;
|
|
272
|
+
editor.setText("");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Total selectable rows for the current question */
|
|
276
|
+
function optionCount(q: NormalizedQuestion): number {
|
|
277
|
+
if (q.type === "text") return 0;
|
|
278
|
+
return q.options.length + (q.allowOther ? 1 : 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isAnswered(q: NormalizedQuestion): boolean {
|
|
282
|
+
if (q.type === "radio") return radioAnswers.has(q.id);
|
|
283
|
+
if (q.type === "checkbox") {
|
|
284
|
+
const set = checkAnswers.get(q.id);
|
|
285
|
+
const custom = checkCustom.get(q.id);
|
|
286
|
+
return (set != null && set.size > 0) || (custom != null && custom.trim().length > 0);
|
|
287
|
+
}
|
|
288
|
+
if (q.type === "text") {
|
|
289
|
+
return (textAnswers.get(q.id)?.trim() ?? "").length > 0;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function allRequired(): boolean {
|
|
295
|
+
return questions.every((q) => !q.required || isAnswered(q));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function switchTab(idx: number) {
|
|
299
|
+
// Save text editor state
|
|
300
|
+
saveEditorText();
|
|
301
|
+
currentTab = ((idx % totalTabs) + totalTabs) % totalTabs;
|
|
302
|
+
cursorIdx = 0;
|
|
303
|
+
otherMode = false;
|
|
304
|
+
otherQuestionId = null;
|
|
305
|
+
|
|
306
|
+
// If switching to a text question, load its value
|
|
307
|
+
const q = curQ();
|
|
308
|
+
if (q?.type === "text") {
|
|
309
|
+
editor.setText(textAnswers.get(q.id) ?? "");
|
|
310
|
+
}
|
|
311
|
+
refresh();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function saveEditorText() {
|
|
315
|
+
const q = curQ();
|
|
316
|
+
if (!q) return;
|
|
317
|
+
if (q.type === "text") {
|
|
318
|
+
const t = editor.getText().trim();
|
|
319
|
+
if (t) textAnswers.set(q.id, t);
|
|
320
|
+
else textAnswers.delete(q.id);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function finishSubmit(cancelled: boolean) {
|
|
325
|
+
saveEditorText();
|
|
326
|
+
const answers: Answer[] = [];
|
|
327
|
+
for (const q of questions) {
|
|
328
|
+
if (q.type === "radio") {
|
|
329
|
+
const a = radioAnswers.get(q.id);
|
|
330
|
+
answers.push({
|
|
331
|
+
id: q.id,
|
|
332
|
+
type: "radio",
|
|
333
|
+
value: a?.value ?? "",
|
|
334
|
+
wasCustom: a?.wasCustom ?? false,
|
|
335
|
+
});
|
|
336
|
+
} else if (q.type === "checkbox") {
|
|
337
|
+
const set = checkAnswers.get(q.id) ?? new Set();
|
|
338
|
+
const custom = checkCustom.get(q.id)?.trim();
|
|
339
|
+
const values = [...set];
|
|
340
|
+
if (custom) values.push(custom);
|
|
341
|
+
answers.push({ id: q.id, type: "checkbox", value: values, wasCustom: !!custom });
|
|
342
|
+
} else {
|
|
343
|
+
const t = textAnswers.get(q.id) ?? "";
|
|
344
|
+
answers.push({ id: q.id, type: "text", value: t, wasCustom: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
done({ title: params.title, questions, answers, cancelled });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Editor submit (for "Other" mode) ────────────────────
|
|
351
|
+
editor.onSubmit = (value) => {
|
|
352
|
+
const trimmed = value.trim();
|
|
353
|
+
if (otherMode && otherQuestionId) {
|
|
354
|
+
const q = questions.find((q) => q.id === otherQuestionId);
|
|
355
|
+
if (q?.type === "radio" && trimmed) {
|
|
356
|
+
radioAnswers.set(q.id, { value: trimmed, label: trimmed, wasCustom: true });
|
|
357
|
+
} else if (q?.type === "checkbox" && trimmed) {
|
|
358
|
+
checkCustom.set(q.id, trimmed);
|
|
359
|
+
}
|
|
360
|
+
otherMode = false;
|
|
361
|
+
otherQuestionId = null;
|
|
362
|
+
editor.setText("");
|
|
363
|
+
|
|
364
|
+
// Auto-advance
|
|
365
|
+
advanceTab();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Text question submit (fallback — Enter is normally intercepted in handleInput
|
|
370
|
+
// before reaching the editor, but handle it here defensively using `value`
|
|
371
|
+
// since editor state is already cleared by the time onSubmit fires)
|
|
372
|
+
const q = curQ();
|
|
373
|
+
if (q?.type === "text") {
|
|
374
|
+
const trimmedValue = value.trim();
|
|
375
|
+
if (trimmedValue) {
|
|
376
|
+
textAnswers.set(q.id, trimmedValue);
|
|
377
|
+
} else {
|
|
378
|
+
textAnswers.delete(q.id);
|
|
379
|
+
}
|
|
380
|
+
advanceTab();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// ── Input handling ───────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
function handleInput(data: string) {
|
|
387
|
+
// "Other" editor mode
|
|
388
|
+
if (otherMode) {
|
|
389
|
+
if (matchesKey(data, Key.escape)) {
|
|
390
|
+
otherMode = false;
|
|
391
|
+
otherQuestionId = null;
|
|
392
|
+
editor.setText("");
|
|
393
|
+
refresh();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Enter: capture text directly from editor (before it clears itself) and advance
|
|
397
|
+
if (matchesKey(data, Key.enter)) {
|
|
398
|
+
saveOtherModeText();
|
|
399
|
+
advanceTab();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Tab navigation in multi-question forms: save text and switch tab
|
|
403
|
+
if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
|
|
404
|
+
saveOtherModeText();
|
|
405
|
+
switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
editor.handleInput(data);
|
|
409
|
+
refresh();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Text question — route most input to editor
|
|
414
|
+
const q = curQ();
|
|
415
|
+
if (q?.type === "text") {
|
|
416
|
+
// Enter: save text (editor still has content here) and advance
|
|
417
|
+
if (matchesKey(data, Key.enter)) {
|
|
418
|
+
saveEditorText();
|
|
419
|
+
advanceTab();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// Tab navigation still works
|
|
423
|
+
if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
|
|
424
|
+
saveEditorText();
|
|
425
|
+
switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (matchesKey(data, Key.escape)) {
|
|
429
|
+
finishSubmit(true);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
editor.handleInput(data);
|
|
433
|
+
refresh();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Submit tab (multi-question only)
|
|
438
|
+
if (isMulti && currentTab === questions.length) {
|
|
439
|
+
if (matchesKey(data, Key.enter) && allRequired()) {
|
|
440
|
+
finishSubmit(false);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
444
|
+
switchTab(0);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
448
|
+
switchTab(currentTab - 1);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (matchesKey(data, Key.escape)) {
|
|
452
|
+
finishSubmit(true);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!q) return;
|
|
459
|
+
|
|
460
|
+
// Tab navigation (multi)
|
|
461
|
+
if (isMulti) {
|
|
462
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
463
|
+
switchTab(currentTab + 1);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
467
|
+
switchTab(currentTab - 1);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Arrow navigation
|
|
473
|
+
const total = optionCount(q);
|
|
474
|
+
if (matchesKey(data, Key.up)) {
|
|
475
|
+
cursorIdx = Math.max(0, cursorIdx - 1);
|
|
476
|
+
refresh();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (matchesKey(data, Key.down)) {
|
|
480
|
+
cursorIdx = Math.min(total - 1, cursorIdx + 1);
|
|
481
|
+
refresh();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Escape
|
|
486
|
+
if (matchesKey(data, Key.escape)) {
|
|
487
|
+
finishSubmit(true);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Radio select
|
|
492
|
+
if (q.type === "radio" && matchesKey(data, Key.enter)) {
|
|
493
|
+
const isOther = q.allowOther && cursorIdx === q.options.length;
|
|
494
|
+
if (isOther) {
|
|
495
|
+
otherMode = true;
|
|
496
|
+
otherQuestionId = q.id;
|
|
497
|
+
// Pre-fill with existing custom answer
|
|
498
|
+
const existing = radioAnswers.get(q.id);
|
|
499
|
+
editor.setText(existing?.wasCustom ? existing.label : "");
|
|
500
|
+
refresh();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const opt = q.options[cursorIdx];
|
|
504
|
+
if (opt) {
|
|
505
|
+
radioAnswers.set(q.id, { value: opt.value, label: opt.label, wasCustom: false });
|
|
506
|
+
advanceTab();
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Checkbox toggle (space only)
|
|
512
|
+
if (q.type === "checkbox" && matchesKey(data, Key.space)) {
|
|
513
|
+
const isOther = q.allowOther && cursorIdx === q.options.length;
|
|
514
|
+
if (isOther) {
|
|
515
|
+
otherMode = true;
|
|
516
|
+
otherQuestionId = q.id;
|
|
517
|
+
editor.setText(checkCustom.get(q.id) ?? "");
|
|
518
|
+
refresh();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const opt = q.options[cursorIdx];
|
|
522
|
+
if (opt) {
|
|
523
|
+
const set = checkAnswers.get(q.id) ?? new Set();
|
|
524
|
+
if (set.has(opt.value)) set.delete(opt.value);
|
|
525
|
+
else set.add(opt.value);
|
|
526
|
+
checkAnswers.set(q.id, set);
|
|
527
|
+
refresh();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Checkbox: Enter submits (single) or advances (multi)
|
|
533
|
+
if (q.type === "checkbox" && matchesKey(data, Key.enter)) {
|
|
534
|
+
advanceTab();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Render ───────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
function render(width: number): string[] {
|
|
542
|
+
if (cachedLines) return cachedLines;
|
|
543
|
+
|
|
544
|
+
const lines: string[] = [];
|
|
545
|
+
const maxW = Math.min(width, 120);
|
|
546
|
+
const add = (s: string) => lines.push(truncateToWidth(s, maxW));
|
|
547
|
+
const hr = () => add(theme.fg("accent", "─".repeat(maxW)));
|
|
548
|
+
|
|
549
|
+
hr();
|
|
550
|
+
|
|
551
|
+
// Title & description
|
|
552
|
+
if (params.title) {
|
|
553
|
+
add(` ${theme.fg("accent", theme.bold(params.title))}`);
|
|
554
|
+
}
|
|
555
|
+
if (params.description) {
|
|
556
|
+
add(` ${theme.fg("muted", params.description)}`);
|
|
557
|
+
}
|
|
558
|
+
if (params.title || params.description) lines.push("");
|
|
559
|
+
|
|
560
|
+
// Tab bar (multi-question)
|
|
561
|
+
if (isMulti) {
|
|
562
|
+
const dividerVisible = visibleWidth("│");
|
|
563
|
+
const tabCount = totalTabs;
|
|
564
|
+
|
|
565
|
+
// Determine each tab's state
|
|
566
|
+
interface TabState {
|
|
567
|
+
isActive: boolean;
|
|
568
|
+
answered: boolean;
|
|
569
|
+
label: string;
|
|
570
|
+
}
|
|
571
|
+
const tabStates: TabState[] = [];
|
|
572
|
+
for (let i = 0; i < questions.length; i++) {
|
|
573
|
+
tabStates.push({
|
|
574
|
+
isActive: i === currentTab,
|
|
575
|
+
answered: isAnswered(questions[i]),
|
|
576
|
+
label: `Q${i + 1}`,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
tabStates.push({
|
|
580
|
+
isActive: currentTab === questions.length,
|
|
581
|
+
answered: allRequired(),
|
|
582
|
+
label: "Submit",
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Prefix widths: "▸ " = 2, "✓ " = 2
|
|
586
|
+
const prefixWidths = tabStates.map((s) => {
|
|
587
|
+
let w = 0;
|
|
588
|
+
if (s.isActive) w += visibleWidth(`${SYM.pointer} `);
|
|
589
|
+
if (s.answered) w += visibleWidth(`${SYM.check} `);
|
|
590
|
+
return w;
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const totalDividers = tabCount - 1;
|
|
594
|
+
const dividerSpace = totalDividers * dividerVisible;
|
|
595
|
+
const paddingSpace = tabCount * 2; // " " around each tab
|
|
596
|
+
const prefixSpace = prefixWidths.reduce((a, b) => a + b, 0);
|
|
597
|
+
const availableForLabels = maxW - dividerSpace - paddingSpace - prefixSpace;
|
|
598
|
+
const minLabelPerTab = 6;
|
|
599
|
+
let maxLabelLen =
|
|
600
|
+
availableForLabels > tabCount * minLabelPerTab
|
|
601
|
+
? Math.floor(availableForLabels / tabCount)
|
|
602
|
+
: minLabelPerTab;
|
|
603
|
+
if (maxLabelLen < minLabelPerTab) maxLabelLen = minLabelPerTab;
|
|
604
|
+
|
|
605
|
+
const tabs: string[] = [];
|
|
606
|
+
for (let i = 0; i < tabStates.length; i++) {
|
|
607
|
+
const s = tabStates[i];
|
|
608
|
+
const rawParts: string[] = [];
|
|
609
|
+
if (s.isActive) rawParts.push(SYM.pointer);
|
|
610
|
+
if (s.answered) rawParts.push(SYM.check);
|
|
611
|
+
const prefix = rawParts.join(" ") + (rawParts.length > 0 ? " " : "");
|
|
612
|
+
const label = truncateToWidth(s.label, Math.max(1, maxLabelLen));
|
|
613
|
+
const rawText = prefix + label;
|
|
614
|
+
let styledText;
|
|
615
|
+
if (s.isActive) {
|
|
616
|
+
styledText = theme.fg("accent", theme.bold(rawText));
|
|
617
|
+
} else {
|
|
618
|
+
const color = s.answered ? "success" : "muted";
|
|
619
|
+
styledText = theme.fg(color, rawText);
|
|
620
|
+
}
|
|
621
|
+
tabs.push(` ${styledText} `);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
add(theme.fg("dim", ` ${tabs.join(theme.fg("dim", "│"))}`));
|
|
625
|
+
lines.push("");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const q = curQ();
|
|
629
|
+
|
|
630
|
+
// ── Submit tab ───────────────────────────────────────
|
|
631
|
+
if (isMulti && currentTab === questions.length) {
|
|
632
|
+
add(` ${theme.fg("accent", theme.bold("Review & Submit"))}`);
|
|
633
|
+
lines.push("");
|
|
634
|
+
|
|
635
|
+
for (const question of questions) {
|
|
636
|
+
const label = theme.fg("muted", `${question.label}:`);
|
|
637
|
+
if (question.type === "radio") {
|
|
638
|
+
const a = radioAnswers.get(question.id);
|
|
639
|
+
if (a) {
|
|
640
|
+
const prefix = a.wasCustom ? theme.fg("dim", "(wrote) ") : "";
|
|
641
|
+
add(` ${label} ${prefix}${a.label}`);
|
|
642
|
+
} else {
|
|
643
|
+
add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
|
|
644
|
+
}
|
|
645
|
+
} else if (question.type === "checkbox") {
|
|
646
|
+
const set = checkAnswers.get(question.id) ?? new Set();
|
|
647
|
+
const custom = checkCustom.get(question.id)?.trim();
|
|
648
|
+
const all = [...set];
|
|
649
|
+
if (custom) all.push(`${theme.fg("dim", "(wrote)")} ${custom}`);
|
|
650
|
+
if (all.length) {
|
|
651
|
+
add(` ${label} ${all.join(", ")}`);
|
|
652
|
+
} else {
|
|
653
|
+
add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
const t = textAnswers.get(question.id)?.trim();
|
|
657
|
+
if (t) {
|
|
658
|
+
add(` ${label} ${truncateToWidth(t, maxW - visibleWidth(question.label) - 5)}`);
|
|
659
|
+
} else {
|
|
660
|
+
add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
lines.push("");
|
|
666
|
+
if (allRequired()) {
|
|
667
|
+
add(` ${theme.fg("success", "Press Enter to submit")}`);
|
|
668
|
+
} else {
|
|
669
|
+
const missing = questions
|
|
670
|
+
.filter((q) => q.required && !isAnswered(q))
|
|
671
|
+
.map((q) => q.label)
|
|
672
|
+
.join(", ");
|
|
673
|
+
add(` ${theme.fg("warning", `Required: ${missing}`)}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
lines.push("");
|
|
677
|
+
add(theme.fg("dim", " Tab/←→ navigate questions • Enter submit • Esc cancel"));
|
|
678
|
+
hr();
|
|
679
|
+
cachedLines = lines;
|
|
680
|
+
return lines;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (!q) {
|
|
684
|
+
hr();
|
|
685
|
+
cachedLines = lines;
|
|
686
|
+
return lines;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── Question prompt ──────────────────────────────────
|
|
690
|
+
const typeTag =
|
|
691
|
+
q.type === "radio"
|
|
692
|
+
? theme.fg("dim", "[single-select]")
|
|
693
|
+
: q.type === "checkbox"
|
|
694
|
+
? theme.fg("dim", "[multi-select]")
|
|
695
|
+
: theme.fg("dim", "[text]");
|
|
696
|
+
|
|
697
|
+
const promptLines = wrapText(q.prompt, maxW - 2);
|
|
698
|
+
for (let i = 0; i < promptLines.length; i++) {
|
|
699
|
+
const isLast = i === promptLines.length - 1;
|
|
700
|
+
add(` ${theme.fg("text", theme.bold(promptLines[i]))}${isLast ? ` ${typeTag}` : ""}`);
|
|
701
|
+
}
|
|
702
|
+
if (q.required) {
|
|
703
|
+
add(` ${theme.fg("warning", "*required")}`);
|
|
704
|
+
}
|
|
705
|
+
lines.push("");
|
|
706
|
+
|
|
707
|
+
// ── Radio options ────────────────────────────────────
|
|
708
|
+
if (q.type === "radio") {
|
|
709
|
+
const selected = radioAnswers.get(q.id);
|
|
710
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
711
|
+
const opt = q.options[i];
|
|
712
|
+
const isCursor = i === cursorIdx;
|
|
713
|
+
const isSelected = selected?.value === opt.value && !selected.wasCustom;
|
|
714
|
+
const bullet = isSelected ? theme.fg("accent", SYM.radioOn) : theme.fg("dim", SYM.radioOff);
|
|
715
|
+
const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
|
|
716
|
+
const color = isCursor ? "accent" : isSelected ? "text" : "muted";
|
|
717
|
+
const prefix = ` ${pointer} ${bullet} `;
|
|
718
|
+
const prefixWidth = visibleWidth(prefix);
|
|
719
|
+
const labelLines = wrapText(opt.label, Math.max(1, maxW - prefixWidth));
|
|
720
|
+
for (let li = 0; li < labelLines.length; li++) {
|
|
721
|
+
const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
|
|
722
|
+
add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
|
|
723
|
+
}
|
|
724
|
+
if (opt.description) {
|
|
725
|
+
const descLines = wrapText(opt.description, Math.max(1, maxW - 6));
|
|
726
|
+
for (const dl of descLines) {
|
|
727
|
+
add(` ${theme.fg("dim", dl)}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (q.allowOther) {
|
|
732
|
+
const isCursor = cursorIdx === q.options.length;
|
|
733
|
+
const isSelected = selected?.wasCustom === true;
|
|
734
|
+
const bullet = isSelected ? theme.fg("accent", SYM.radioOn) : theme.fg("dim", SYM.radioOff);
|
|
735
|
+
const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
|
|
736
|
+
const label = isSelected ? `Other: ${selected.label}` : "Other...";
|
|
737
|
+
const prefix = ` ${pointer} ${bullet} `;
|
|
738
|
+
const prefixWidth = visibleWidth(prefix);
|
|
739
|
+
const labelLines = wrapText(label, Math.max(1, maxW - prefixWidth));
|
|
740
|
+
const color = isCursor ? "accent" : "muted";
|
|
741
|
+
for (let li = 0; li < labelLines.length; li++) {
|
|
742
|
+
const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
|
|
743
|
+
add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (otherMode) {
|
|
747
|
+
lines.push("");
|
|
748
|
+
add(` ${theme.fg("muted", " Your answer:")}`);
|
|
749
|
+
for (const line of editor.render(maxW - 6)) {
|
|
750
|
+
add(` ${line}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── Checkbox options ─────────────────────────────────
|
|
757
|
+
if (q.type === "checkbox") {
|
|
758
|
+
const set = checkAnswers.get(q.id) ?? new Set();
|
|
759
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
760
|
+
const opt = q.options[i];
|
|
761
|
+
const isCursor = i === cursorIdx;
|
|
762
|
+
const isChecked = set.has(opt.value);
|
|
763
|
+
const box = isChecked ? theme.fg("accent", SYM.checkOn) : theme.fg("dim", SYM.checkOff);
|
|
764
|
+
const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
|
|
765
|
+
const color = isCursor ? "accent" : isChecked ? "text" : "muted";
|
|
766
|
+
const prefix = ` ${pointer} ${box} `;
|
|
767
|
+
const prefixWidth = visibleWidth(prefix);
|
|
768
|
+
const labelLines = wrapText(opt.label, Math.max(1, maxW - prefixWidth));
|
|
769
|
+
for (let li = 0; li < labelLines.length; li++) {
|
|
770
|
+
const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
|
|
771
|
+
add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
|
|
772
|
+
}
|
|
773
|
+
if (opt.description) {
|
|
774
|
+
const descLines = wrapText(opt.description, Math.max(1, maxW - 6));
|
|
775
|
+
for (const dl of descLines) {
|
|
776
|
+
add(` ${theme.fg("dim", dl)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (q.allowOther) {
|
|
781
|
+
const isCursor = cursorIdx === q.options.length;
|
|
782
|
+
const custom = checkCustom.get(q.id)?.trim();
|
|
783
|
+
const box = custom ? theme.fg("accent", SYM.checkOn) : theme.fg("dim", SYM.checkOff);
|
|
784
|
+
const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
|
|
785
|
+
const label = custom ? `Other: ${custom}` : "Other...";
|
|
786
|
+
const prefix = ` ${pointer} ${box} `;
|
|
787
|
+
const prefixWidth = visibleWidth(prefix);
|
|
788
|
+
const labelLines = wrapText(label, Math.max(1, maxW - prefixWidth));
|
|
789
|
+
const color = isCursor ? "accent" : "muted";
|
|
790
|
+
for (let li = 0; li < labelLines.length; li++) {
|
|
791
|
+
const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
|
|
792
|
+
add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Text input ───────────────────────────────────────
|
|
798
|
+
if (q.type === "text") {
|
|
799
|
+
if (q.placeholder && !editor.getText()) {
|
|
800
|
+
add(` ${theme.fg("dim", q.placeholder)}`);
|
|
801
|
+
}
|
|
802
|
+
for (const line of editor.render(maxW - 4)) {
|
|
803
|
+
add(` ${line}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ── Footer ───────────────────────────────────────────
|
|
808
|
+
lines.push("");
|
|
809
|
+
if (otherMode) {
|
|
810
|
+
add(theme.fg("dim", " Enter submit • Esc go back"));
|
|
811
|
+
} else if (q.type === "text") {
|
|
812
|
+
const nav = isMulti ? "Tab/←→ navigate • " : "";
|
|
813
|
+
add(theme.fg("dim", ` ${nav}Enter submit • Esc cancel`));
|
|
814
|
+
} else if (q.type === "checkbox") {
|
|
815
|
+
const nav = isMulti ? "Tab/←→ navigate • " : "";
|
|
816
|
+
add(theme.fg("dim", ` ↑↓ navigate • Space toggle • ${nav}Enter ${isMulti ? "next" : "submit"} • Esc cancel`));
|
|
817
|
+
} else {
|
|
818
|
+
const nav = isMulti ? "Tab/←→ navigate • " : "";
|
|
819
|
+
add(theme.fg("dim", ` ↑↓ navigate • ${nav}Enter select • Esc cancel`));
|
|
820
|
+
}
|
|
821
|
+
hr();
|
|
822
|
+
|
|
823
|
+
cachedLines = lines;
|
|
824
|
+
return lines;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Initialize: if first question is text, load editor
|
|
828
|
+
const firstQ = questions[0];
|
|
829
|
+
if (firstQ?.type === "text") {
|
|
830
|
+
editor.setText(textAnswers.get(firstQ.id) ?? "");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
render,
|
|
835
|
+
invalidate: () => {
|
|
836
|
+
cachedLines = undefined;
|
|
837
|
+
},
|
|
838
|
+
handleInput,
|
|
839
|
+
};
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ── Format result ────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
if (result.cancelled) {
|
|
845
|
+
return {
|
|
846
|
+
content: [{ type: "text", text: "User cancelled the form" }],
|
|
847
|
+
details: result,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const answerLines: string[] = [];
|
|
852
|
+
for (const a of result.answers) {
|
|
853
|
+
const q = questions.find((q) => q.id === a.id);
|
|
854
|
+
const label = q?.label || a.id;
|
|
855
|
+
if (a.type === "radio") {
|
|
856
|
+
const prefix = a.wasCustom ? "(wrote) " : "";
|
|
857
|
+
answerLines.push(`${label}: ${prefix}${a.value}`);
|
|
858
|
+
} else if (a.type === "checkbox") {
|
|
859
|
+
const values = Array.isArray(a.value) ? a.value : [a.value];
|
|
860
|
+
if (values.length === 0) {
|
|
861
|
+
answerLines.push(`${label}: (none selected)`);
|
|
862
|
+
} else {
|
|
863
|
+
answerLines.push(`${label}: ${values.join(", ")}`);
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
answerLines.push(`${label}: ${a.value || "(empty)"}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
content: [{ type: "text", text: answerLines.join("\n") }],
|
|
872
|
+
details: result,
|
|
873
|
+
};
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
// ── Custom rendering ─────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
renderCall(args, theme, _context) {
|
|
879
|
+
const qs = (args.questions as Question[]) || [];
|
|
880
|
+
const title = args.title as string | undefined;
|
|
881
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user_question "));
|
|
882
|
+
if (title) {
|
|
883
|
+
text += theme.fg("accent", title) + " ";
|
|
884
|
+
}
|
|
885
|
+
text += theme.fg("muted", `${qs.length} question${qs.length !== 1 ? "s" : ""}`);
|
|
886
|
+
const types = [...new Set(qs.map((q) => q.type))].join(", ");
|
|
887
|
+
if (types) {
|
|
888
|
+
text += theme.fg("dim", ` (${types})`);
|
|
889
|
+
}
|
|
890
|
+
return new Text(text, 0, 0);
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
renderResult(result, _options, theme, _context) {
|
|
894
|
+
const details = result.details as FormResult | undefined;
|
|
895
|
+
if (!details) {
|
|
896
|
+
const text = result.content[0];
|
|
897
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (details.cancelled) {
|
|
901
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const lines = details.answers.map((a) => {
|
|
905
|
+
const q = details.questions.find((q) => q.id === a.id);
|
|
906
|
+
const label = q?.label || a.id;
|
|
907
|
+
|
|
908
|
+
if (a.type === "radio") {
|
|
909
|
+
const prefix = a.wasCustom ? theme.fg("dim", "(wrote) ") : "";
|
|
910
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${prefix}${a.value}`;
|
|
911
|
+
}
|
|
912
|
+
if (a.type === "checkbox") {
|
|
913
|
+
const values = Array.isArray(a.value) ? a.value : [a.value];
|
|
914
|
+
const display = values.length ? values.join(", ") : theme.fg("dim", "(none)");
|
|
915
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${display}`;
|
|
916
|
+
}
|
|
917
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${a.value || theme.fg("dim", "(empty)")}`;
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
}
|