@zhushanwen/pi-ask-user 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/src/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ // src/index.ts
2
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
3
+ import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
4
+ import { type Static } from "@sinclair/typebox";
5
+
6
+ import { AskUserComponent } from "./component";
7
+ import {
8
+ type AskUserDetails,
9
+ type ErrorDetails,
10
+ HEADER_MAX_CHARS,
11
+ InputSchema,
12
+ type Option,
13
+ type Question,
14
+ type Result,
15
+ type ThemeLike,
16
+ } from "./types";
17
+ import { validateInput } from "./validate";
18
+
19
+ /**
20
+ * execute 的返回形状:复用 SDK AgentToolResult<AskUserDetails>,叠加运行时使用的 isError 标记。
21
+ * SDK 的 AgentToolResult 类型未声明 isError(运行时通过它标记错误),这里显式补齐。
22
+ */
23
+ type ExecuteResult = AgentToolResult<AskUserDetails> & { isError?: boolean };
24
+
25
+ /**
26
+ * expanded 渲染辅助:展开某问题的全部选项,用 ●/○ 标记是否被选中(spec FR-9)。
27
+ * 选中判定:answer 含该 option label(answer 形如 "Postgres" / "A, B" / "X — comment")。
28
+ * 返回 TruncatedText 数组供 box.addChild 展开。
29
+ */
30
+ function renderExpandedOptions(
31
+ q: Question,
32
+ answer: string,
33
+ theme: ThemeLike,
34
+ ): TruncatedText[] {
35
+ const answerTokens = new Set(answer.split(/\s*[,—]\s*|\s*,\s*/).filter(Boolean));
36
+ const mark = (opt: Option): string =>
37
+ answerTokens.has(opt.label) || answer.includes(opt.label)
38
+ ? theme.fg("success", "●")
39
+ : theme.fg("dim", "○");
40
+ // 只展开真实选项(不含自动追加的 Other);Other 文本单独显示
41
+ const out: TruncatedText[] = q.options.map(
42
+ (opt: Option) =>
43
+ new TruncatedText(
44
+ theme.fg("dim", " ") +
45
+ mark(opt) +
46
+ theme.fg("text", ` ${opt.label}`),
47
+ 0,
48
+ 0,
49
+ ),
50
+ );
51
+ return out;
52
+ }
53
+
54
+ export default function (pi: ExtensionAPI): void {
55
+ pi.registerTool({
56
+ name: "ask_user",
57
+ label: "Ask User",
58
+ description: `Ask the user to resolve ambiguity you cannot resolve yourself. Use ONLY when ALL hold: (1) the request has ≥2 reasonable approaches, (2) you have already gathered context (read/grep) and the answer is still genuinely ambiguous, and (3) picking wrong means redoing real work. One question = one decision with mutually exclusive options.
59
+
60
+ Do NOT use this tool to outsource judgment you should make — if you can form a defensible recommendation from the codebase, proceed and state your choice. Do NOT use for trivia answerable by reading code/docs, or for simple confirmations ("I'll delete X") where plain text suffices. You cannot use this tool to collect free-form requirements, long-form feedback, or multi-paragraph input — it returns short selections only.
61
+
62
+ If you recommend an option, prefix its label with "(Recommended)" and list it first. For structured multi-option decisions, prefer this tool over plain-text questions; for everything else, reply in plain text.`,
63
+ promptSnippet:
64
+ "Ask the user structured clarifying questions with options — only when you cannot resolve the ambiguity yourself",
65
+ promptGuidelines: [
66
+ "Use ask_user only when the request has ≥2 reasonable approaches you cannot resolve from context. Models over-ask because asking feels safer than deciding — resist this: if context makes the answer clear, proceed without asking.",
67
+ "Gather context first (read/grep) and pass a short summary via the context field — don't ask blind. If the answer becomes clear after gathering context, proceed and state your choice.",
68
+ "Ask focused questions; each question = one decision with mutually exclusive options. Batch related decisions into one call (1-4 questions).",
69
+ "Do NOT use ask_user for trivia answerable by reading code/docs, or to confirm simple actions ('I'll delete X') — plain text suffices there.",
70
+ "Do NOT outsource judgment you can make yourself: if you can form a defensible recommendation from the codebase, proceed and state it instead of asking.",
71
+ "Do NOT include an 'Other' option yourself — it is always available automatically.",
72
+ ],
73
+ parameters: InputSchema,
74
+
75
+ async execute(
76
+ _toolCallId: string,
77
+ params: Static<typeof InputSchema>,
78
+ signal: AbortSignal | undefined,
79
+ _onUpdate: unknown,
80
+ ctx: ExtensionContext,
81
+ ): Promise<ExecuteResult> {
82
+ const questions = params.questions;
83
+
84
+ // 1. 参数校验(spec FR-2)→ isError
85
+ const validationError = validateInput(questions);
86
+ if (validationError) {
87
+ return {
88
+ content: [{ type: "text" as const, text: `Error: ${validationError}` }],
89
+ isError: true,
90
+ details: { questions, answers: {}, cancelled: true } satisfies Result,
91
+ };
92
+ }
93
+
94
+ // 2. Headless 检查(spec FR-8)→ isError + 禁用工具
95
+ if (!ctx.hasUI) {
96
+ pi.setActiveTools(
97
+ pi
98
+ .getAllTools()
99
+ .map((t: { name: string }) => t.name)
100
+ .filter((n: string) => n !== "ask_user"),
101
+ );
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text" as const,
106
+ text: "Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
107
+ },
108
+ ],
109
+ isError: true,
110
+ details: { questions, answers: {}, cancelled: true } satisfies Result,
111
+ };
112
+ }
113
+
114
+ // 3. Signal abort 入口检查(spec FR-10)
115
+ if (signal?.aborted) {
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text" as const,
120
+ text: "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
121
+ },
122
+ ],
123
+ details: { questions, answers: {}, cancelled: true } satisfies Result,
124
+ };
125
+ }
126
+
127
+ // 4. 顶层 try/catch(spec FR-13)
128
+ let result: Result | null;
129
+ try {
130
+ result = await ctx.ui.custom<Result | null>(
131
+ (tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
132
+ const comp = new AskUserComponent(
133
+ questions,
134
+ tui as { requestRender(): void },
135
+ theme as ThemeLike,
136
+ done,
137
+ );
138
+ // signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
139
+ // 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
140
+ if (signal) {
141
+ signal.addEventListener("abort", () => comp.cancel(), { once: true });
142
+ }
143
+ return comp;
144
+ },
145
+ // 不传 options → inline 渲染(spec FR-3)
146
+ );
147
+ } catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text" as const,
153
+ text: `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
154
+ },
155
+ ],
156
+ isError: true,
157
+ details: { error: message } satisfies ErrorDetails,
158
+ };
159
+ }
160
+
161
+ // 5. 取消(null / cancelled)
162
+ if (result === null || result.cancelled) {
163
+ return {
164
+ content: [
165
+ {
166
+ type: "text" as const,
167
+ text: "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
168
+ },
169
+ ],
170
+ details: { questions, answers: {}, cancelled: true } satisfies Result,
171
+ };
172
+ }
173
+
174
+ // 6. 正常返回
175
+ const summary = result.questions.map(
176
+ (q: Question) => `"${q.question}" = "${result!.answers[q.question] ?? "(no answer)"}"`,
177
+ );
178
+ return {
179
+ content: [{ type: "text" as const, text: summary.join("\n") }],
180
+ details: result satisfies Result,
181
+ };
182
+ },
183
+
184
+ renderCall(args: Static<typeof InputSchema>, theme: ThemeLike) {
185
+ const questions: Question[] = args.questions ?? [];
186
+ const topics = questions.map((q) => q.header ?? truncateToWidth(q.question, HEADER_MAX_CHARS)).join(", ");
187
+ return new TruncatedText(
188
+ theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", topics),
189
+ 0,
190
+ 0,
191
+ );
192
+ },
193
+
194
+ renderResult(
195
+ result: AgentToolResult<AskUserDetails>,
196
+ options: ToolRenderResultOptions,
197
+ theme: ThemeLike,
198
+ ) {
199
+ const details = result.details;
200
+ if (details && "error" in details && details.error) {
201
+ return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
202
+ }
203
+ // details 现已排除 ErrorDetails 分支,收窄为 Result | undefined
204
+ const d = details as Result | undefined;
205
+ if (!d || d.cancelled) {
206
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
207
+ }
208
+ const box = new Box(0, 0);
209
+ for (const q of d.questions) {
210
+ const header = q.header ?? truncateToWidth(q.question, HEADER_MAX_CHARS);
211
+ const answer = d.answers[q.question] ?? "(no answer)";
212
+ box.addChild(
213
+ new TruncatedText(
214
+ theme.fg("success", "✓ ") +
215
+ theme.fg("accent", `${header}: `) +
216
+ theme.fg("text", answer),
217
+ 0,
218
+ 0,
219
+ ),
220
+ );
221
+ // options.expanded:展开显示该问题全部选项 + ●/○ 选中标记 + 评论(spec FR-9)
222
+ if (options?.expanded) {
223
+ for (const child of renderExpandedOptions(q, answer, theme)) box.addChild(child);
224
+ }
225
+ }
226
+ return box;
227
+ },
228
+ });
229
+ }
@@ -0,0 +1,317 @@
1
+ // src/question-view.ts
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
+
4
+ import {
5
+ OTHER_LABEL,
6
+ type Question,
7
+ type QuestionState,
8
+ SPLIT_PANE_LEFT_MIN,
9
+ SPLIT_PANE_MIN_WIDTH,
10
+ SPLIT_PANE_RIGHT_MIN,
11
+ SPLIT_PANE_SEPARATOR,
12
+ type ThemeLike,
13
+ } from "./types";
14
+
15
+ export interface DisplayOption {
16
+ label: string;
17
+ description?: string;
18
+ isOther?: boolean;
19
+ }
20
+
21
+ /** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
22
+ const MAX_EDITOR_LINES = 5;
23
+
24
+ /**
25
+ * 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
26
+ * - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
27
+ * - 超过 maxLines:截断到 maxLines 行,并在最后一行末尾用 ellipsis 提示。
28
+ * 此时末行可能已不含末尾光标字符,用 ellipsis 占位表达「还有更多」。
29
+ *
30
+ * @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
31
+ * @param lead 首行前缀(含选中标记 / 勾选框)
32
+ * @param content 待换行展示的已样式化文本(可含 ANSI + 末尾光标 █),为空则只输出 lead
33
+ * @param availWidth 单行可用宽度
34
+ * @param maxLines 最多行数
35
+ */
36
+ function addWrappedInput(
37
+ push: (s: string) => void,
38
+ lead: string,
39
+ content: string,
40
+ availWidth: number,
41
+ maxLines: number,
42
+ ): void {
43
+ const avail = Math.max(1, availWidth);
44
+ const indent = " ".repeat(visibleWidth(lead));
45
+ if (content === "") {
46
+ push(lead);
47
+ return;
48
+ }
49
+ let wrapped = wrapTextWithAnsi(content, avail);
50
+ if (wrapped.length > maxLines) {
51
+ wrapped = wrapped.slice(0, maxLines);
52
+ // 最后一行去掉超出,用省略号占位(光标已被省略号取代)
53
+ const lastIdx = wrapped.length - 1;
54
+ wrapped[lastIdx] = truncateToWidth(wrapped[lastIdx]!, Math.max(1, avail - 1), "…");
55
+ }
56
+ for (let i = 0; i < wrapped.length; i++) {
57
+ const seg = wrapped[i]!;
58
+ push(i === 0 ? `${lead}${seg}` : `${indent}${seg}`);
59
+ }
60
+ }
61
+
62
+ /** 在选项数组末尾追加 Other 自由输入项。 */
63
+ export function allOptions(q: Question): DisplayOption[] {
64
+ return [...q.options, { label: OTHER_LABEL, isOther: true }];
65
+ }
66
+
67
+ /**
68
+ * 宽终端(≥SPLIT_PANE_MIN_WIDTH)计算左右分屏宽度。窄终端返回 null(单列模式)。
69
+ */
70
+ export function getSplitPaneWidths(width: number): { left: number; right: number } | null {
71
+ if (width < SPLIT_PANE_MIN_WIDTH) return null;
72
+ const available = width - SPLIT_PANE_SEPARATOR.length;
73
+ if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
74
+ const preferredLeft = Math.floor(available * 0.42);
75
+ const left = Math.max(
76
+ SPLIT_PANE_LEFT_MIN,
77
+ Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
78
+ );
79
+ const right = available - left;
80
+ if (right < SPLIT_PANE_RIGHT_MIN) return null;
81
+ return { left, right };
82
+ }
83
+
84
+ /** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
85
+ * freeform 模式下,Other 行**原地**变 [ ] <input>█(多选)/ <input>█(单选),
86
+ * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
87
+ function buildOptionLines(
88
+ q: Question,
89
+ state: QuestionState,
90
+ theme: ThemeLike,
91
+ width: number,
92
+ hideDescriptions: boolean,
93
+ editorText: string = "",
94
+ ): string[] {
95
+ const t = theme;
96
+ const opts = allOptions(q);
97
+ const lines: string[] = [];
98
+ const add = (s: string): void => {
99
+ lines.push(truncateToWidth(s, width));
100
+ };
101
+
102
+ for (let i = 0; i < opts.length; i++) {
103
+ const opt = opts[i]!;
104
+ const isSelected = i === state.cursorIndex;
105
+ const isOther = opt.isOther === true;
106
+ const prefix = isSelected ? t.fg("accent", ">") : " ";
107
+
108
+ if (isOther) {
109
+ if (state.mode === "freeform") {
110
+ // 原地编辑:与多选普通选项的 [ ] 框视觉对齐(单选无勾选语义则留空)
111
+ const box = q.multiSelect ? t.fg("dim", "[ ]") : " ";
112
+ const lead = `${prefix} ${box} `;
113
+ const avail = Math.max(1, width - visibleWidth(lead));
114
+ // 文本 + 末尾光标 █ 整体软换行(空 input 时仅光标,wrapTextWithAnsi("█") 单行)
115
+ const styled = `${t.fg("text", editorText)}${t.fg("accent", "█")}`;
116
+ addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
117
+ } else {
118
+ const hasFreeText = state.freeTextValue !== null;
119
+ const check = hasFreeText ? t.fg("success", "✓") : " ";
120
+ const labelColor = isSelected ? "accent" : "muted";
121
+ const num = i + 1;
122
+ add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
123
+ if (hasFreeText) {
124
+ // 已保存 freeText 预览:软换行展示,最多 MAX_EDITOR_LINES 行(不再单行截断)
125
+ const lead = " "; // 5 列缩进,与上方 label 行对齐
126
+ const avail = Math.max(1, width - visibleWidth(lead));
127
+ const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
128
+ addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
129
+ }
130
+ }
131
+ } else if (q.multiSelect) {
132
+ const checked = state.selectedIndices.has(i);
133
+ const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
134
+ const labelColor = isSelected ? "accent" : "text";
135
+ const num = i + 1;
136
+ add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
137
+ if (opt.description && !hideDescriptions) {
138
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 7);
139
+ for (const line of wrapped) add(` ${line}`);
140
+ }
141
+ } else {
142
+ const isConfirmed = state.selectedIndex === i;
143
+ const check = isConfirmed ? t.fg("success", "✓") : " ";
144
+ const labelColor = isSelected ? "accent" : "text";
145
+ const num = i + 1;
146
+ add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
147
+ if (opt.description && !hideDescriptions) {
148
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 5);
149
+ for (const line of wrapped) add(` ${line}`);
150
+ }
151
+ }
152
+ }
153
+ return lines;
154
+ }
155
+
156
+ /** 构建分屏右侧 Markdown 详情预览。 */
157
+ function buildPreviewLines(
158
+ q: Question,
159
+ state: QuestionState,
160
+ theme: ThemeLike,
161
+ width: number,
162
+ maxLines: number,
163
+ ): string[] {
164
+ const t = theme;
165
+ const opts = allOptions(q);
166
+ const opt = opts[state.cursorIndex];
167
+ if (!opt) return [t.fg("dim", "—")];
168
+
169
+ let text = "";
170
+ if (opt.isOther) {
171
+ text = `${opt.label}: enter a custom answer not listed above.`;
172
+ } else {
173
+ text = opt.label;
174
+ if (opt.description?.trim()) text += `\n\n${opt.description}`;
175
+ }
176
+
177
+ const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(10, width));
178
+ const lines = wrapped.slice(0, maxLines);
179
+ if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
180
+ return lines;
181
+ }
182
+
183
+ /**
184
+ * 渲染单个问题视图(spec FR-4)。
185
+ * isSingle: 单问题模式(无 Tab 提示)。
186
+ * editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
187
+ */
188
+ /**
189
+ * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
190
+ * buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
191
+ * comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
192
+ */
193
+ function buildEditorBlock(
194
+ theme: ThemeLike,
195
+ width: number,
196
+ mode: "freeform" | "comment",
197
+ editorText: string,
198
+ ): string[] {
199
+ if (mode === "freeform") {
200
+ return [""];
201
+ }
202
+ const t = theme;
203
+ const lines: string[] = [];
204
+ const add = (s: string): void => {
205
+ lines.push(truncateToWidth(s, width));
206
+ };
207
+ add("");
208
+ const prompt = t.fg("muted", " Your comment (optional):");
209
+ add(prompt);
210
+ // 渲染当前编辑器文本(单行;多行时按 \n 拆分)
211
+ for (const line of editorText.split("\n")) add(` ${line}`);
212
+ // 光标行
213
+ add(` ${t.fg("accent", "█")}`);
214
+ add("");
215
+ add(t.fg("dim", " Enter submit · Esc back"));
216
+ return lines;
217
+ }
218
+
219
+ /** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
220
+ function buildSplitPane(
221
+ q: Question,
222
+ state: QuestionState,
223
+ theme: ThemeLike,
224
+ split: { left: number; right: number },
225
+ width: number,
226
+ editorText: string = "",
227
+ ): string[] {
228
+ const t = theme;
229
+ const lines: string[] = [];
230
+ const add = (s: string): void => {
231
+ lines.push(truncateToWidth(s, width));
232
+ };
233
+ const leftLines = buildOptionLines(q, state, theme, split.left, true, editorText);
234
+ const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
235
+ const rowCount = Math.max(leftLines.length, rightLines.length);
236
+ const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
237
+ for (let i = 0; i < rowCount; i++) {
238
+ const left = truncateToWidth(leftLines[i] ?? "", split.left, "", true);
239
+ const right = truncateToWidth(rightLines[i] ?? "", split.right);
240
+ add(`${left}${sep}${right}`);
241
+ }
242
+ return lines;
243
+ }
244
+
245
+ export function renderQuestionView(
246
+ q: Question,
247
+ state: QuestionState,
248
+ theme: ThemeLike,
249
+ width: number,
250
+ isSingle: boolean,
251
+ editorText: string,
252
+ ): string[] {
253
+ const t = theme;
254
+ const lines: string[] = [];
255
+ const add = (s: string): void => {
256
+ lines.push(truncateToWidth(s, width));
257
+ };
258
+ const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
259
+
260
+ // 问题文本(word-wrap)
261
+ const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - 2);
262
+ for (const line of wrapped) add(line);
263
+
264
+ // 上下文(如有)
265
+ if (q.context?.trim()) {
266
+ divider();
267
+ const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - 2);
268
+ for (const line of ctxWrapped) add(line);
269
+ }
270
+
271
+ // 分屏判断
272
+ const split = getSplitPaneWidths(width);
273
+
274
+ // 选项模式下:question/context 与 options 之间加分割线(三段式)
275
+ // 编辑器模式不加(编辑器块自带视觉边界)
276
+ if (state.mode !== "freeform" && state.mode !== "comment") {
277
+ divider();
278
+ }
279
+
280
+ // 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines 原地渲染)
281
+ if (state.mode === "freeform" || state.mode === "comment") {
282
+ add("");
283
+ const optionLines = buildOptionLines(q, state, theme, split ? split.left : width, !!split, editorText);
284
+ for (const line of optionLines) add(line);
285
+ const editorBlock = buildEditorBlock(theme, width, state.mode, editorText);
286
+ lines.push(...editorBlock);
287
+ if (state.mode === "freeform") {
288
+ // freeform 模式 help 行:光标锁在 Other 上,正在输入
289
+ add(t.fg("dim", " Enter submit · Esc back"));
290
+ }
291
+ return lines;
292
+ }
293
+
294
+ if (!split) {
295
+ // 单列模式
296
+ const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
297
+ for (const line of optionLines) add(line);
298
+ } else {
299
+ // 分屏模式
300
+ lines.push(...buildSplitPane(q, state, theme, split, width, editorText));
301
+ }
302
+
303
+ add("");
304
+
305
+ // 帮助行(上下文相关)
306
+ const opts = allOptions(q);
307
+ const onOther = state.cursorIndex === opts.length - 1;
308
+ const tabHint = isSingle ? "" : " · Tab switch tabs";
309
+ const actionHint = onOther
310
+ ? "Enter open editor"
311
+ : q.multiSelect
312
+ ? "Space toggle · Enter confirm"
313
+ : "Enter select";
314
+ add(t.fg("dim", ` ↑↓ navigate · ${actionHint}${tabHint} · Esc back`));
315
+
316
+ return lines;
317
+ }
@@ -0,0 +1,114 @@
1
+ // src/submit-view.ts
2
+ import { truncateToWidth } from "@mariozechner/pi-tui";
3
+
4
+ import {
5
+ ANSWER_COMMENT_SEPARATOR,
6
+ HEADER_MAX_CHARS,
7
+ type Question,
8
+ type QuestionState,
9
+ type Result,
10
+ type ThemeLike,
11
+ } from "./types";
12
+
13
+ /**
14
+ * 获取单问题的答案文本(供 Submit tab 显示)。
15
+ * 返回 null 表示未答。
16
+ */
17
+ export function getAnswerText(q: Question, s: QuestionState): string | null {
18
+ if (!s.confirmed) return null;
19
+ const parts: string[] = [];
20
+ if (q.multiSelect) {
21
+ const labels = [...s.selectedIndices]
22
+ .sort((a, b) => a - b)
23
+ .map((idx) => q.options[idx]?.label)
24
+ .filter((l): l is string => !!l);
25
+ parts.push(...labels);
26
+ } else if (s.selectedIndex !== null) {
27
+ const label = q.options[s.selectedIndex]?.label;
28
+ if (label) parts.push(label);
29
+ }
30
+ if (s.freeTextValue !== null) parts.push(s.freeTextValue);
31
+ if (parts.length === 0) return null;
32
+ const base = parts.join(", ");
33
+ return s.commentValue ? `${base}${ANSWER_COMMENT_SEPARATOR}${s.commentValue}` : base;
34
+ }
35
+
36
+ /**
37
+ * 渲染 Submit tab 视图。
38
+ * focus: 当前在 [Submit]/[Cancel] 上的焦点(←/→ 切换)。
39
+ * 渲染内嵌按钮栏高亮 focus;help 行更新为 "←/→ toggle · Enter confirm"。
40
+ */
41
+ export function renderSubmitView(
42
+ questions: Question[],
43
+ states: QuestionState[],
44
+ theme: ThemeLike,
45
+ width: number,
46
+ focus: "submit" | "cancel" = "submit",
47
+ ): string[] {
48
+ const t = theme;
49
+ const lines: string[] = [];
50
+ const add = (s: string): void => {
51
+ lines.push(truncateToWidth(s, width));
52
+ };
53
+
54
+ const allDone = states.every((s) => s.confirmed);
55
+
56
+ add(
57
+ allDone
58
+ ? t.fg("success", t.bold(" Ready to submit"))
59
+ : t.fg("warning", t.bold(" Unanswered questions")),
60
+ );
61
+ add("");
62
+
63
+ for (let i = 0; i < questions.length; i++) {
64
+ const q = questions[i]!;
65
+ const answer = getAnswerText(q, states[i]!);
66
+ const headerLabel = truncateToWidth(q.header ?? "", HEADER_MAX_CHARS);
67
+ if (answer !== null) {
68
+ add(` ${t.fg("muted", `${headerLabel}: `)}${t.fg("text", answer)}`);
69
+ } else {
70
+ add(` ${t.fg("dim", `${headerLabel}: `)}${t.fg("warning", "—")}`);
71
+ }
72
+ }
73
+
74
+ add("");
75
+ if (allDone) {
76
+ add(t.fg("success", " All questions answered"));
77
+ } else {
78
+ const missing = questions
79
+ .filter((_, i) => !states[i]!.confirmed)
80
+ .map((q) => truncateToWidth(q.header ?? "", HEADER_MAX_CHARS))
81
+ .join(", ");
82
+ add(t.fg("warning", ` Still needed: ${missing}`));
83
+ }
84
+
85
+ // 内嵌按钮栏:[ Submit ] [ Cancel ],根据 focus 高亮
86
+ add("");
87
+ const isSubmit = focus === "submit";
88
+ const isCancel = focus === "cancel";
89
+ const submitBtn = isSubmit
90
+ ? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
91
+ : (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
92
+ const cancelBtn = isCancel ? t.fg("accent", t.bold(" Cancel ")) : t.fg("muted", " Cancel ");
93
+ add(`${t.fg("dim", "[")}${submitBtn}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancelBtn}${t.fg("dim", "]")}`);
94
+
95
+ // Submit tab 帮助行
96
+ add("");
97
+ add(t.fg("dim", " ←/→ toggle · Enter confirm · Tab back to first question"));
98
+
99
+ return lines;
100
+ }
101
+
102
+ /**
103
+ * 从 states 构建 Result(供组件 buildResult 调用)。
104
+ */
105
+ export function buildResult(questions: Question[], states: QuestionState[]): Result {
106
+ const answers: Record<string, string> = {};
107
+ for (let i = 0; i < questions.length; i++) {
108
+ const q = questions[i]!;
109
+ const s = states[i]!;
110
+ const text = getAnswerText(q, s);
111
+ if (text !== null) answers[q.question] = text;
112
+ }
113
+ return { questions, answers, cancelled: false };
114
+ }