@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.
- package/CHANGELOG.md +194 -0
- package/LICENSE +22 -0
- package/README.md +282 -0
- package/docs/README.md +33 -0
- package/docs/configuration.md +406 -0
- package/docs/contract.md +309 -0
- package/docs/remote-events.md +187 -0
- package/package.json +130 -0
- package/skills/ask-user/SKILL.md +110 -0
- package/src/answer-commands.ts +361 -0
- package/src/answer-extraction.ts +354 -0
- package/src/ask-payload-store.ts +86 -0
- package/src/ask-settings-command.ts +14 -0
- package/src/ask-tool-helpers.ts +172 -0
- package/src/ask-tool.ts +84 -0
- package/src/config/defaults.ts +216 -0
- package/src/config/migrate.ts +70 -0
- package/src/config/migrations/index.ts +139 -0
- package/src/config/migrations/types.ts +10 -0
- package/src/config/schema.ts +287 -0
- package/src/config/store.ts +227 -0
- package/src/constants/keymaps.ts +721 -0
- package/src/constants/text.ts +12 -0
- package/src/constants/ui.ts +22 -0
- package/src/index.ts +30 -0
- package/src/math.ts +3 -0
- package/src/notifications.ts +119 -0
- package/src/remote-ask.ts +563 -0
- package/src/result-format.ts +157 -0
- package/src/result.ts +23 -0
- package/src/schema.ts +74 -0
- package/src/state/answers.ts +251 -0
- package/src/state/create.ts +18 -0
- package/src/state/editor.ts +70 -0
- package/src/state/navigation.ts +86 -0
- package/src/state/normalize.ts +326 -0
- package/src/state/question-type.ts +128 -0
- package/src/state/result.ts +263 -0
- package/src/state/selectors.ts +135 -0
- package/src/state/transitions.ts +330 -0
- package/src/state/view.ts +28 -0
- package/src/text.ts +98 -0
- package/src/types.ts +169 -0
- package/src/ui/auto-submit.ts +36 -0
- package/src/ui/autocomplete.ts +52 -0
- package/src/ui/controller.ts +645 -0
- package/src/ui/dismiss-guard.ts +26 -0
- package/src/ui/input.ts +160 -0
- package/src/ui/render-frame.ts +235 -0
- package/src/ui/render-helpers.ts +385 -0
- package/src/ui/render-question.ts +288 -0
- package/src/ui/render-submit.ts +168 -0
- package/src/ui/render-types.ts +33 -0
- package/src/ui/render.ts +53 -0
- package/src/ui/review-shortcuts.ts +43 -0
- package/src/ui/settings-list.ts +461 -0
- package/src/ui/show-settings.ts +37 -0
- package/src/ui/view-models/question.ts +203 -0
- 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
|
+
}
|