@zhushanwen/pi-ask-user 0.0.3 → 0.1.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.
@@ -2,6 +2,7 @@
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
3
 
4
4
  import {
5
+ isHighSurrogate,
5
6
  OTHER_LABEL,
6
7
  type Question,
7
8
  type QuestionState,
@@ -9,9 +10,16 @@ import {
9
10
  SPLIT_PANE_MIN_WIDTH,
10
11
  SPLIT_PANE_RIGHT_MIN,
11
12
  SPLIT_PANE_SEPARATOR,
13
+ SURROGATE_PAIR_LEN,
12
14
  type ThemeLike,
13
15
  } from "./types";
14
16
 
17
+ const SPLIT_PANE_LEFT_RATIO = 0.42;
18
+ const DESCRIPTION_INDENT_MULTI = 10;
19
+ const DESCRIPTION_INDENT_SINGLE = 8;
20
+ const PREVIEW_MIN_WIDTH = 10;
21
+ const QUESTION_TEXT_MARGIN = 2;
22
+
15
23
  export interface DisplayOption {
16
24
  label: string;
17
25
  description?: string;
@@ -21,6 +29,21 @@ export interface DisplayOption {
21
29
  /** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
22
30
  const MAX_EDITOR_LINES = 5;
23
31
 
32
+ /**
33
+ * 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
34
+ * surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
35
+ * 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
36
+ * 光标在文本末尾(超出范围)时反色高亮一个空格占位。
37
+ */
38
+ function renderCursorText(text: string, cursorPos: number): string {
39
+ const before = text.slice(0, cursorPos);
40
+ // 光标在高代理前 → 反色高亮整个 surrogate pair
41
+ const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
42
+ const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
43
+ const after = text.slice(cursorPos + charLen);
44
+ return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
45
+ }
46
+
24
47
  /**
25
48
  * 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
26
49
  * - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
@@ -29,7 +52,7 @@ const MAX_EDITOR_LINES = 5;
29
52
  *
30
53
  * @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
31
54
  * @param lead 首行前缀(含选中标记 / 勾选框)
32
- * @param content 待换行展示的已样式化文本(可含 ANSI + 末尾光标 █),为空则只输出 lead
55
+ * @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
33
56
  * @param availWidth 单行可用宽度
34
57
  * @param maxLines 最多行数
35
58
  */
@@ -71,7 +94,7 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
71
94
  if (width < SPLIT_PANE_MIN_WIDTH) return null;
72
95
  const available = width - SPLIT_PANE_SEPARATOR.length;
73
96
  if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
74
- const preferredLeft = Math.floor(available * 0.42);
97
+ const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
75
98
  const left = Math.max(
76
99
  SPLIT_PANE_LEFT_MIN,
77
100
  Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
@@ -81,8 +104,12 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
81
104
  return { left, right };
82
105
  }
83
106
 
107
+ /** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
108
+ * 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
109
+ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
110
+
84
111
  /** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
85
- * freeform 模式下,Other 行**原地**变 [ ] <input>█(多选)/ <input>█(单选),
112
+ * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
86
113
  * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
87
114
  function buildOptionLines(
88
115
  q: Question,
@@ -90,7 +117,7 @@ function buildOptionLines(
90
117
  theme: ThemeLike,
91
118
  width: number,
92
119
  hideDescriptions: boolean,
93
- editorText: string = "",
120
+ draftText: string = "",
94
121
  ): string[] {
95
122
  const t = theme;
96
123
  const opts = allOptions(q);
@@ -101,30 +128,41 @@ function buildOptionLines(
101
128
 
102
129
  for (let i = 0; i < opts.length; i++) {
103
130
  const opt = opts[i]!;
104
- const isSelected = i === state.cursorIndex;
131
+ // 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
132
+ const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
133
+ const isSelected = i === activeOptionCursor;
105
134
  const isOther = opt.isOther === true;
106
135
  const prefix = isSelected ? t.fg("accent", ">") : " ";
107
136
 
108
137
  if (isOther) {
138
+ // 标记位宽度必须与普通选项一致,否则编号列错位:
139
+ // 单选 check = 1 列,多选 box = 3 列。
140
+ // 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
141
+ // 两种情况下 Other 编号都与普通选项错位。
109
142
  if (state.mode === "freeform") {
110
- // 原地编辑:与普通选项的 [ ] 框视觉对齐(单选无勾选语义则留空)。
111
- // lead = "> [ ] ",编号 + 输入文本跟在框后,内容起始列与普通选项一致。
112
- const box = q.multiSelect ? t.fg("dim", "[ ]") : " ";
143
+ const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
113
144
  const num = i + 1;
114
- const lead = `${prefix} ${box} `;
145
+ const lead = `${prefix} ${marker} `;
115
146
  const avail = Math.max(1, width - visibleWidth(lead));
116
- // 编号 + 文本 + 末尾光标 █ 整体软换行(空 input 时仅编号 + 光标,wrapTextWithAnsi 单行)
117
- const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", editorText)}${t.fg("accent", "█")}`;
147
+ // 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
148
+ const cursorText = renderCursorText(draftText, state.cursorIndex);
149
+ const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
118
150
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
119
151
  } else {
120
152
  const hasFreeText = state.freeTextValue !== null;
121
- const check = hasFreeText ? t.fg("success", "✓") : " ";
122
- const labelColor = isSelected ? "accent" : "muted";
153
+ const marker = q.multiSelect
154
+ ? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
155
+ : (hasFreeText ? t.fg("success", "✓") : " ");
156
+ const labelColor = isSelected ? "accent" : "text";
123
157
  const num = i + 1;
124
- add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
158
+ add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
125
159
  if (hasFreeText) {
126
- // 已保存 freeText 预览:软换行展示,最多 MAX_EDITOR_LINES 行(不再单行截断)
127
- const lead = " "; // 6 列缩进,与上方 label 行对齐(prefix1 + sp1 + check1 + sp1 + num2 = 6)
160
+ // 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
161
+ // 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
162
+ const numStr = `${num}.`;
163
+ const lead = " ".repeat(
164
+ visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
165
+ );
128
166
  const avail = Math.max(1, width - visibleWidth(lead));
129
167
  const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
130
168
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
@@ -137,7 +175,7 @@ function buildOptionLines(
137
175
  const num = i + 1;
138
176
  add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
139
177
  if (opt.description && !hideDescriptions) {
140
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 10);
178
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
141
179
  for (const line of wrapped) add(` ${line}`);
142
180
  }
143
181
  } else {
@@ -147,7 +185,7 @@ function buildOptionLines(
147
185
  const num = i + 1;
148
186
  add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
149
187
  if (opt.description && !hideDescriptions) {
150
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 8);
188
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
151
189
  for (const line of wrapped) add(` ${line}`);
152
190
  }
153
191
  }
@@ -176,19 +214,14 @@ function buildPreviewLines(
176
214
  if (opt.description?.trim()) text += `\n\n${opt.description}`;
177
215
  }
178
216
 
179
- const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(10, width));
217
+ const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
180
218
  const lines = wrapped.slice(0, maxLines);
181
219
  if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
182
220
  return lines;
183
221
  }
184
222
 
185
223
  /**
186
- * 渲染单个问题视图(spec FR-4)。
187
- * isSingle: 单问题模式(无 Tab 提示)。
188
- * editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
189
- */
190
- /**
191
- * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
224
+ * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
192
225
  * buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
193
226
  * comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
194
227
  */
@@ -196,7 +229,8 @@ function buildEditorBlock(
196
229
  theme: ThemeLike,
197
230
  width: number,
198
231
  mode: "freeform" | "comment",
199
- editorText: string,
232
+ draftText: string,
233
+ cursorIndex?: number,
200
234
  ): string[] {
201
235
  if (mode === "freeform") {
202
236
  return [""];
@@ -209,12 +243,12 @@ function buildEditorBlock(
209
243
  add("");
210
244
  const prompt = t.fg("muted", " Your comment (optional):");
211
245
  add(prompt);
212
- // 渲染当前编辑器文本(单行;多行时按 \n 拆分)
213
- for (const line of editorText.split("\n")) add(` ${line}`);
214
- // 光标行
215
- add(` ${t.fg("accent", "█")}`);
246
+ // 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
247
+ const pos = cursorIndex ?? draftText.length;
248
+ const cursorText = renderCursorText(draftText, pos);
249
+ add(` ${t.fg("text", cursorText)}`);
216
250
  add("");
217
- add(t.fg("dim", " Enter submit · Esc back"));
251
+ add(t.fg("dim", EDITOR_HINT));
218
252
  return lines;
219
253
  }
220
254
 
@@ -225,14 +259,14 @@ function buildSplitPane(
225
259
  theme: ThemeLike,
226
260
  split: { left: number; right: number },
227
261
  width: number,
228
- editorText: string = "",
262
+ draftText: string = "",
229
263
  ): string[] {
230
264
  const t = theme;
231
265
  const lines: string[] = [];
232
266
  const add = (s: string): void => {
233
267
  lines.push(truncateToWidth(s, width));
234
268
  };
235
- const leftLines = buildOptionLines(q, state, theme, split.left, true, editorText);
269
+ const leftLines = buildOptionLines(q, state, theme, split.left, true, draftText);
236
270
  const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
237
271
  const rowCount = Math.max(leftLines.length, rightLines.length);
238
272
  const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
@@ -244,13 +278,18 @@ function buildSplitPane(
244
278
  return lines;
245
279
  }
246
280
 
281
+ /**
282
+ * 渲染单个问题视图(spec FR-4)。
283
+ * isSingle: 单问题模式(无 Tab 提示)。
284
+ * draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
285
+ */
247
286
  export function renderQuestionView(
248
287
  q: Question,
249
288
  state: QuestionState,
250
289
  theme: ThemeLike,
251
290
  width: number,
252
291
  isSingle: boolean,
253
- editorText: string,
292
+ draftText: string,
254
293
  ): string[] {
255
294
  const t = theme;
256
295
  const lines: string[] = [];
@@ -260,13 +299,13 @@ export function renderQuestionView(
260
299
  const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
261
300
 
262
301
  // 问题文本(word-wrap)
263
- const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - 2);
302
+ const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
264
303
  for (const line of wrapped) add(line);
265
304
 
266
305
  // 上下文(如有)
267
306
  if (q.context?.trim()) {
268
307
  divider();
269
- const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - 2);
308
+ const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
270
309
  for (const line of ctxWrapped) add(line);
271
310
  }
272
311
 
@@ -284,24 +323,24 @@ export function renderQuestionView(
284
323
  // 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
285
324
  if (state.mode === "freeform" || state.mode === "comment") {
286
325
  add("");
287
- const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
326
+ const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
288
327
  for (const line of optionLines) add(line);
289
- const editorBlock = buildEditorBlock(theme, width, state.mode, editorText);
328
+ const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
290
329
  lines.push(...editorBlock);
291
330
  if (state.mode === "freeform") {
292
331
  // freeform 模式 help 行:光标锁在 Other 上,正在输入
293
- add(t.fg("dim", " Enter submit · Esc back"));
332
+ add(t.fg("dim", EDITOR_HINT));
294
333
  }
295
334
  return lines;
296
335
  }
297
336
 
298
337
  if (!split) {
299
338
  // 单列模式
300
- const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
339
+ const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
301
340
  for (const line of optionLines) add(line);
302
341
  } else {
303
342
  // 分屏模式
304
- lines.push(...buildSplitPane(q, state, theme, split, width, editorText));
343
+ lines.push(...buildSplitPane(q, state, theme, split, width, draftText));
305
344
  }
306
345
 
307
346
  add("");
@@ -10,6 +10,22 @@ import {
10
10
  type ThemeLike,
11
11
  } from "./types";
12
12
 
13
+ /** 渲染 [ Submit ] [ Cancel ] 按钮栏,返回带样式的单行字符串。
14
+ * focus: null=纯展示(问题 tab footer),"submit"/"cancel"=高亮对应按钮(Submit tab)。
15
+ * 统一了 component.ts footer 版与 submit-view 内嵌版的样式逻辑。 */
16
+ export function renderButtonBar(theme: ThemeLike, allDone: boolean, focus: "submit" | "cancel" | null): string {
17
+ const t = theme;
18
+ const isSubmit = focus === "submit";
19
+ const isCancel = focus === "cancel";
20
+ const submit = isSubmit
21
+ ? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
22
+ : (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
23
+ const cancel = isCancel
24
+ ? t.fg("accent", t.bold(" Cancel "))
25
+ : t.fg("muted", " Cancel ");
26
+ return `${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`;
27
+ }
28
+
13
29
  /**
14
30
  * 获取单问题的答案文本(供 Submit tab 显示)。
15
31
  * 返回 null 表示未答。
@@ -84,13 +100,7 @@ export function renderSubmitView(
84
100
 
85
101
  // 内嵌按钮栏:[ Submit ] [ Cancel ],根据 focus 高亮
86
102
  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", "]")}`);
103
+ add(renderButtonBar(t, allDone, focus));
94
104
 
95
105
  // Submit tab 帮助行
96
106
  add("");
package/src/types.ts CHANGED
@@ -30,7 +30,7 @@ export const OptionSchema = Type.Object({
30
30
  export const QuestionSchema = Type.Object({
31
31
  question: Type.String({
32
32
  description:
33
- "Full question text. Must be one self-contained decision; avoid multi-part questions. Plain text only (no newlines or control characters).",
33
+ "Full question text. Must be one self-contained decision; avoid multi-part questions. ≤1000 chars; plain single-line text only (no newlines or control characters).",
34
34
  }),
35
35
  header: Type.Optional(
36
36
  Type.String({
@@ -63,7 +63,6 @@ export const InputSchema = Type.Object({
63
63
  // ── 派生类型 ─────────────────────────────────────────
64
64
  export type Option = Static<typeof OptionSchema>;
65
65
  export type Question = Static<typeof QuestionSchema>;
66
- export type Input = Static<typeof InputSchema>;
67
66
 
68
67
  // ── Result schema(details,renderResult 数据源) ─────
69
68
  export const ResultSchema = Type.Object({
@@ -108,10 +107,17 @@ export interface QuestionState {
108
107
  confirmed: boolean;
109
108
  /** Other 自由文本答案;null=未输入 */
110
109
  freeTextValue: string | null;
110
+ /** freeform Esc 保存的未提交草稿;null=无草稿。
111
+ * 与 freeTextValue(已提交答案)分离,避免放弃的草稿污染答案、触发 auto-confirm。 */
112
+ freeDraft: string | null;
111
113
  /** 可选评论;null=未输入 */
112
114
  commentValue: string | null;
113
115
  /** 当前交互模式 */
114
116
  mode: QuestionMode;
117
+ /** 编辑器草稿文本(每问题独立持有,进编辑器时预填、退出时清空) */
118
+ draftText: string;
119
+ /** 进入编辑器前保存的 options 光标位置(退出编辑器时恢复) */
120
+ savedOptionsCursorIndex: number;
115
121
  }
116
122
 
117
123
  /** 创建初始 QuestionState */
@@ -122,7 +128,23 @@ export function createQuestionState(): QuestionState {
122
128
  selectedIndices: new Set<number>(),
123
129
  confirmed: false,
124
130
  freeTextValue: null,
131
+ freeDraft: null,
125
132
  commentValue: null,
126
133
  mode: "options",
134
+ draftText: "",
135
+ savedOptionsCursorIndex: 0,
127
136
  };
128
137
  }
138
+
139
+ // ── UTF-16 surrogate pair 工具(编辑器光标移动/删除/渲染共用) ──
140
+ /** 高代理位掩码:charCode & 0xFC00 === 0xD800 判定 surrogate pair 前半 */
141
+ export const SURROGATE_HIGH_MASK = 0xFC00;
142
+ /** 高代理起始码点(surrogate pair 前半的判定值) */
143
+ export const SURROGATE_HIGH_START = 0xD800;
144
+ /** 一个 surrogate pair 占用的 UTF-16 code unit 数 */
145
+ export const SURROGATE_PAIR_LEN = 2;
146
+
147
+ /** 检查 s[i] 是否是 UTF-16 高代理(surrogate pair 的前半部分) */
148
+ export function isHighSurrogate(s: string, i: number): boolean {
149
+ return (s.charCodeAt(i) & SURROGATE_HIGH_MASK) === SURROGATE_HIGH_START;
150
+ }
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/validate.ts
2
- import { type Question,QUESTION_MAX_CHARS } from "./types";
2
+ import { HEADER_MAX_CHARS, type Question, QUESTION_MAX_CHARS } from "./types";
3
3
 
4
4
  /** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
5
5
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
@@ -57,5 +57,13 @@ export function validateInput(questions: Question[]): string | null {
57
57
  }
58
58
  }
59
59
 
60
+ // 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
61
+ // 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
62
+ for (const q of questions) {
63
+ if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
64
+ return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, 20)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
65
+ }
66
+ }
67
+
60
68
  return null;
61
69
  }