@geoqiao/pi-ask 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,135 @@
1
+ import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants/text.ts";
2
+ import type {
3
+ AskDisplayOption,
4
+ AskQuestion,
5
+ AskState,
6
+ AskStateAnswer,
7
+ } from "../types.ts";
8
+ import { isAnswerAnswered } from "./answers.ts";
9
+
10
+ const CUSTOM_OPTION: AskDisplayOption = {
11
+ value: OTHER_OPTION_VALUE,
12
+ label: OTHER_OPTION_LABEL,
13
+ isCustomOption: true,
14
+ };
15
+
16
+ const DEFAULT_FREEFORM_LABEL = "Type your answer:";
17
+
18
+ export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
19
+ return state.questions[state.activeTabIndex];
20
+ }
21
+
22
+ export function getQuestionById(
23
+ state: AskState,
24
+ questionId: string
25
+ ): AskQuestion | undefined {
26
+ return state.questions.find((question) => question.id === questionId);
27
+ }
28
+
29
+ export function isSubmitTab(state: AskState): boolean {
30
+ return state.activeTabIndex >= state.questions.length;
31
+ }
32
+
33
+ export function getRenderableOptions(
34
+ question?: AskQuestion
35
+ ): AskDisplayOption[] {
36
+ if (!question) {
37
+ return [];
38
+ }
39
+ const freeformOption = getFreeformOption(question);
40
+ if (freeformOption) {
41
+ return [
42
+ {
43
+ ...freeformOption,
44
+ label: DEFAULT_FREEFORM_LABEL,
45
+ isCustomOption: true,
46
+ isFreeformOnlyOption: true,
47
+ },
48
+ ];
49
+ }
50
+ return [...question.options, CUSTOM_OPTION];
51
+ }
52
+
53
+ function getFreeformOption(
54
+ question: AskQuestion
55
+ ): AskDisplayOption | undefined {
56
+ if (question.options.length !== 1) {
57
+ return;
58
+ }
59
+ const option = question.options[0];
60
+ return option.freeform ? option : undefined;
61
+ }
62
+
63
+ export function getCurrentOption(
64
+ state: AskState
65
+ ): AskDisplayOption | undefined {
66
+ return getRenderableOptions(getCurrentQuestion(state))[
67
+ state.activeOptionIndex
68
+ ];
69
+ }
70
+
71
+ export function getQuestionOptionByValue(
72
+ question: AskQuestion,
73
+ optionValue: string
74
+ ) {
75
+ return question.options.find((option) => option.value === optionValue);
76
+ }
77
+
78
+ export function getAnswer(
79
+ state: AskState,
80
+ questionId: string
81
+ ): AskStateAnswer | undefined {
82
+ return state.answers[questionId];
83
+ }
84
+
85
+ export function getQuestionNote(
86
+ state: AskState,
87
+ questionId: string
88
+ ): string | undefined {
89
+ return getAnswer(state, questionId)?.note;
90
+ }
91
+
92
+ export function getOptionNote(
93
+ state: AskState,
94
+ questionId: string,
95
+ optionValue: string
96
+ ): string | undefined {
97
+ return getAnswer(state, questionId)?.optionNotes?.[optionValue];
98
+ }
99
+
100
+ export function isQuestionAnswered(
101
+ state: AskState,
102
+ questionId: string
103
+ ): boolean {
104
+ return isAnswerAnswered(getAnswer(state, questionId));
105
+ }
106
+
107
+ export function isQuestionNoteOpen(
108
+ state: AskState,
109
+ questionId: string
110
+ ): boolean {
111
+ return (
112
+ state.view.kind === "note" &&
113
+ state.view.questionId === questionId &&
114
+ state.view.optionValue === undefined
115
+ );
116
+ }
117
+
118
+ export function isOptionNoteOpen(
119
+ state: AskState,
120
+ questionId: string,
121
+ optionValue: string
122
+ ): boolean {
123
+ return (
124
+ state.view.kind === "note" &&
125
+ state.view.questionId === questionId &&
126
+ state.view.optionValue === optionValue
127
+ );
128
+ }
129
+
130
+ export function isInputOpenForQuestion(
131
+ state: AskState,
132
+ questionId: string
133
+ ): boolean {
134
+ return state.view.kind === "input" && state.view.questionId === questionId;
135
+ }
@@ -0,0 +1,330 @@
1
+ import type { AskAction, AskState } from "../types.ts";
2
+ import {
3
+ emptyAnswer,
4
+ isAnswerAnswered,
5
+ isAnswerEmpty,
6
+ saveCustomText,
7
+ saveOptionNote,
8
+ saveQuestionNote,
9
+ setCustomSelected,
10
+ setSingleSelection,
11
+ toggleSelection,
12
+ } from "./answers.ts";
13
+ import {
14
+ cancelFlow as cancelFlowBase,
15
+ createInitialState as createInitialStateBase,
16
+ dismissFlow as dismissFlowBase,
17
+ moveOption as moveOptionBase,
18
+ moveTab as moveTabBase,
19
+ } from "./navigation.ts";
20
+ import {
21
+ getAnswer,
22
+ getCurrentOption,
23
+ getCurrentQuestion,
24
+ getQuestionById,
25
+ getRenderableOptions,
26
+ isSubmitTab,
27
+ } from "./selectors.ts";
28
+ import {
29
+ inputView,
30
+ navigateView,
31
+ optionNoteView,
32
+ questionNoteView,
33
+ submitView,
34
+ } from "./view.ts";
35
+
36
+ const SUBMIT_ACTION_COUNT = 3;
37
+
38
+ export function createInitialState(params: {
39
+ title?: string;
40
+ questions: AskState["questions"];
41
+ }): AskState {
42
+ return createInitialStateBase(params);
43
+ }
44
+
45
+ export function moveTab(state: AskState, delta: number): AskState {
46
+ return moveTabBase(state, delta);
47
+ }
48
+
49
+ export function moveOption(state: AskState, delta: number): AskState {
50
+ return moveOptionBase(state, delta);
51
+ }
52
+
53
+ export function cancelFlow(state: AskState): AskState {
54
+ return cancelFlowBase(state);
55
+ }
56
+
57
+ export function dismissFlow(state: AskState): AskState {
58
+ return dismissFlowBase(state);
59
+ }
60
+
61
+ export function reduceAskState(state: AskState, action: AskAction): AskState {
62
+ switch (action.type) {
63
+ case "MOVE_TAB":
64
+ return moveTabBase(state, action.delta);
65
+ case "MOVE_OPTION":
66
+ return moveOptionBase(state, action.delta);
67
+ case "OPEN_INPUT":
68
+ return setView(state, inputView(action.questionId));
69
+ case "OPEN_QUESTION_NOTE":
70
+ return setView(state, questionNoteView(action.questionId));
71
+ case "OPEN_OPTION_NOTE":
72
+ return setView(
73
+ state,
74
+ optionNoteView(action.questionId, action.optionValue)
75
+ );
76
+ case "CONFIRM":
77
+ return confirmCurrentSelection(state);
78
+ case "TOGGLE_MULTI":
79
+ return toggleCurrentMultiOption(state);
80
+ case "NUMBER_SHORTCUT":
81
+ return applyNumberShortcut(state, action.digit);
82
+ case "SAVE_INPUT":
83
+ return saveInputValue(state, action.value, action.submit ?? false);
84
+ case "SAVE_NOTE":
85
+ return saveNoteValue(state, action.value);
86
+ case "CANCEL":
87
+ return cancelFlowBase(state);
88
+ default:
89
+ return state;
90
+ }
91
+ }
92
+
93
+ export function enterInputMode(state: AskState, questionId: string): AskState {
94
+ return reduceAskState(state, { type: "OPEN_INPUT", questionId });
95
+ }
96
+
97
+ export function enterQuestionNoteMode(
98
+ state: AskState,
99
+ questionId: string
100
+ ): AskState {
101
+ return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId });
102
+ }
103
+
104
+ export function enterOptionNoteMode(
105
+ state: AskState,
106
+ questionId: string,
107
+ optionValue: string
108
+ ): AskState {
109
+ return reduceAskState(state, {
110
+ type: "OPEN_OPTION_NOTE",
111
+ questionId,
112
+ optionValue,
113
+ });
114
+ }
115
+
116
+ export function toggleCurrentOption(state: AskState): AskState {
117
+ return activateCurrentOption(state, "toggle");
118
+ }
119
+
120
+ export function toggleCurrentMultiOption(state: AskState): AskState {
121
+ return toggleCurrentOption(state);
122
+ }
123
+
124
+ export function confirmCurrentSelection(state: AskState): AskState {
125
+ if (isSubmitTab(state)) {
126
+ return completeSubmitAction(state);
127
+ }
128
+ return activateCurrentOption(state, "confirm");
129
+ }
130
+
131
+ export function applyNumberShortcut(state: AskState, digit: number): AskState {
132
+ if (digit <= 0) {
133
+ return state;
134
+ }
135
+
136
+ if (isSubmitTab(state)) {
137
+ if (digit > SUBMIT_ACTION_COUNT) {
138
+ return state;
139
+ }
140
+ return confirmCurrentSelection({
141
+ ...state,
142
+ activeSubmitActionIndex: digit - 1,
143
+ });
144
+ }
145
+
146
+ const question = getCurrentQuestion(state);
147
+ const index = digit - 1;
148
+ const option = question ? getRenderableOptions(question)[index] : undefined;
149
+ if (!(question && option)) {
150
+ return state;
151
+ }
152
+
153
+ return activateCurrentOption({ ...state, activeOptionIndex: index }, "digit");
154
+ }
155
+
156
+ export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
157
+ return saveInputValue(state, rawValue, false);
158
+ }
159
+
160
+ export function submitCustomAnswer(
161
+ state: AskState,
162
+ rawValue: string
163
+ ): AskState {
164
+ return saveInputValue(state, rawValue, true);
165
+ }
166
+
167
+ export function saveNote(state: AskState, rawValue: string): AskState {
168
+ return saveNoteValue(state, rawValue);
169
+ }
170
+
171
+ function completeSubmitAction(state: AskState): AskState {
172
+ if (state.activeSubmitActionIndex === 2) {
173
+ return { ...state, cancelled: true, completed: true };
174
+ }
175
+ if (state.activeSubmitActionIndex === 1) {
176
+ return { ...state, mode: "elaborate", completed: true };
177
+ }
178
+ return { ...state, mode: "submit", completed: true };
179
+ }
180
+
181
+ function activateCurrentOption(
182
+ state: AskState,
183
+ trigger: "toggle" | "confirm" | "digit"
184
+ ): AskState {
185
+ const question = getCurrentQuestion(state);
186
+ const option = getCurrentOption(state);
187
+ if (!(question && option)) {
188
+ return state;
189
+ }
190
+ if (option.isCustomOption) {
191
+ return activateCustomOption(state, question.id, question.type, trigger);
192
+ }
193
+ if (question.type === "multi") {
194
+ if (trigger === "confirm") {
195
+ return advanceToNextTab(state);
196
+ }
197
+ return updateAnswer(state, question.id, (answer) =>
198
+ toggleSelection(answer, option, state.activeOptionIndex)
199
+ );
200
+ }
201
+
202
+ const nextState = updateAnswer(state, question.id, (answer) => {
203
+ if (trigger === "toggle") {
204
+ const isSelected = answer.selected.some(
205
+ (selection) => selection.value === option.value
206
+ );
207
+ if (isSelected) {
208
+ return {
209
+ ...answer,
210
+ selected: [],
211
+ };
212
+ }
213
+ }
214
+ return setSingleSelection(answer, option, state.activeOptionIndex);
215
+ });
216
+ return trigger === "toggle" ? nextState : advanceToNextTab(nextState);
217
+ }
218
+
219
+ function activateCustomOption(
220
+ state: AskState,
221
+ questionId: string,
222
+ questionType: AskState["questions"][number]["type"],
223
+ trigger: "toggle" | "confirm" | "digit"
224
+ ): AskState {
225
+ if (questionType === "multi" && trigger !== "confirm") {
226
+ const answer = getAnswer(state, questionId);
227
+ if (answer?.customText?.trim()) {
228
+ return updateAnswer(state, questionId, (currentAnswer) =>
229
+ setCustomSelected(currentAnswer, !answer.customSelected)
230
+ );
231
+ }
232
+ }
233
+ return setView(state, inputView(questionId));
234
+ }
235
+
236
+ function saveInputValue(
237
+ state: AskState,
238
+ rawValue: string,
239
+ submit: boolean
240
+ ): AskState {
241
+ if (state.view.kind !== "input") {
242
+ return state;
243
+ }
244
+
245
+ const question = getQuestionById(state, state.view.questionId);
246
+ if (!question) {
247
+ return exitEditingView(state);
248
+ }
249
+
250
+ const nextState = updateAnswer(
251
+ exitEditingView(state),
252
+ question.id,
253
+ (answer) =>
254
+ saveCustomText(
255
+ answer,
256
+ rawValue,
257
+ question.type === "multi" ? "multi" : "single"
258
+ )
259
+ );
260
+ if (
261
+ question.type === "multi" ||
262
+ !(submit && isAnswerAnswered(nextState.answers[question.id]))
263
+ ) {
264
+ return nextState;
265
+ }
266
+ return advanceToNextTab(nextState);
267
+ }
268
+
269
+ function saveNoteValue(state: AskState, rawValue: string): AskState {
270
+ if (state.view.kind !== "note") {
271
+ return exitEditingView(state);
272
+ }
273
+
274
+ const { questionId, optionValue } = state.view;
275
+ const nextState = updateAnswer(
276
+ exitEditingView(state),
277
+ questionId,
278
+ (answer) =>
279
+ optionValue
280
+ ? saveOptionNote(answer, optionValue, rawValue)
281
+ : saveQuestionNote(answer, rawValue)
282
+ );
283
+ return nextState;
284
+ }
285
+
286
+ function setView(state: AskState, view: AskState["view"]): AskState {
287
+ return {
288
+ ...state,
289
+ view,
290
+ };
291
+ }
292
+
293
+ function exitEditingView(state: AskState): AskState {
294
+ return {
295
+ ...state,
296
+ view: isSubmitTab(state) ? submitView() : navigateView(),
297
+ };
298
+ }
299
+
300
+ function advanceToNextTab(state: AskState): AskState {
301
+ const nextTab = Math.min(state.activeTabIndex + 1, state.questions.length);
302
+ return {
303
+ ...state,
304
+ activeTabIndex: nextTab,
305
+ activeOptionIndex: 0,
306
+ activeSubmitActionIndex: 0,
307
+ view: nextTab === state.questions.length ? submitView() : navigateView(),
308
+ };
309
+ }
310
+
311
+ function updateAnswer(
312
+ state: AskState,
313
+ questionId: string,
314
+ mutate: (
315
+ answer: ReturnType<typeof emptyAnswer>
316
+ ) => ReturnType<typeof emptyAnswer>
317
+ ): AskState {
318
+ const existing = getAnswer(state, questionId) ?? emptyAnswer();
319
+ const nextAnswer = mutate(existing);
320
+ const answers = { ...state.answers };
321
+ if (isAnswerEmpty(nextAnswer)) {
322
+ delete answers[questionId];
323
+ } else {
324
+ answers[questionId] = nextAnswer;
325
+ }
326
+ return {
327
+ ...state,
328
+ answers,
329
+ };
330
+ }
@@ -0,0 +1,28 @@
1
+ import type { AskState, ViewState } from "../types.ts";
2
+
3
+ export function navigateView(): ViewState {
4
+ return { kind: "navigate" };
5
+ }
6
+
7
+ export function submitView(): ViewState {
8
+ return { kind: "submit" };
9
+ }
10
+
11
+ export function inputView(questionId: string): ViewState {
12
+ return { kind: "input", questionId };
13
+ }
14
+
15
+ export function questionNoteView(questionId: string): ViewState {
16
+ return { kind: "note", questionId };
17
+ }
18
+
19
+ export function optionNoteView(
20
+ questionId: string,
21
+ optionValue: string
22
+ ): ViewState {
23
+ return { kind: "note", questionId, optionValue };
24
+ }
25
+
26
+ export function isEditingView(state: AskState): boolean {
27
+ return state.view.kind === "input" || state.view.kind === "note";
28
+ }
package/src/text.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const LEADING_WHITESPACE_PATTERN = /^\s*/;
4
+ const WORD_SPLIT_PATTERN = /\s+/;
5
+
6
+ export function wrapText(text: string, width: number): string[] {
7
+ const effectiveWidth = Math.max(1, width);
8
+ const lines = text
9
+ .split("\n")
10
+ .flatMap((paragraph) =>
11
+ wrapParagraphWithIndentation(paragraph, effectiveWidth)
12
+ );
13
+
14
+ return lines.length > 0 ? lines : [""];
15
+ }
16
+
17
+ function wrapParagraphWithIndentation(
18
+ paragraph: string,
19
+ width: number
20
+ ): string[] {
21
+ if (paragraph.length === 0) {
22
+ return [""];
23
+ }
24
+
25
+ const leadingWhitespace =
26
+ paragraph.match(LEADING_WHITESPACE_PATTERN)?.[0] ?? "";
27
+ const body = paragraph.slice(leadingWhitespace.length).trim();
28
+ if (!body) {
29
+ return [leadingWhitespace];
30
+ }
31
+
32
+ const wrapped = wrapParagraph(body, width - visibleWidth(leadingWhitespace));
33
+ if (wrapped.length === 0) {
34
+ return [leadingWhitespace];
35
+ }
36
+
37
+ return wrapped.map((part) => `${leadingWhitespace}${part}`);
38
+ }
39
+
40
+ function wrapParagraph(text: string, width: number): string[] {
41
+ const effectiveWidth = Math.max(1, width);
42
+ const words = text.split(WORD_SPLIT_PATTERN).filter(Boolean);
43
+ const lines: string[] = [];
44
+ let current = "";
45
+
46
+ for (const word of words) {
47
+ if (!current) {
48
+ if (visibleWidth(word) <= effectiveWidth) {
49
+ current = word;
50
+ } else {
51
+ lines.push(...breakLongWord(word, effectiveWidth));
52
+ }
53
+ continue;
54
+ }
55
+
56
+ const candidate = `${current} ${word}`;
57
+ if (visibleWidth(candidate) <= effectiveWidth) {
58
+ current = candidate;
59
+ continue;
60
+ }
61
+
62
+ lines.push(current);
63
+ if (visibleWidth(word) <= effectiveWidth) {
64
+ current = word;
65
+ } else {
66
+ lines.push(...breakLongWord(word, effectiveWidth));
67
+ current = "";
68
+ }
69
+ }
70
+
71
+ if (current) {
72
+ lines.push(current);
73
+ }
74
+
75
+ return lines;
76
+ }
77
+
78
+ function breakLongWord(word: string, width: number): string[] {
79
+ const effectiveWidth = Math.max(1, width);
80
+ const chunks: string[] = [];
81
+ let current = "";
82
+
83
+ for (const char of Array.from(word)) {
84
+ const candidate = `${current}${char}`;
85
+ if (visibleWidth(candidate) > effectiveWidth && current) {
86
+ chunks.push(current);
87
+ current = char;
88
+ continue;
89
+ }
90
+ current = candidate;
91
+ }
92
+
93
+ if (current) {
94
+ chunks.push(current);
95
+ }
96
+
97
+ return chunks;
98
+ }