@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,160 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+ import type { AskConfig } from "../config/schema.ts";
3
+ import {
4
+ type AskKeyBinding,
5
+ getAskContextBindings,
6
+ getGlobalBindings,
7
+ matchesBinding,
8
+ matchesDigitShortcut,
9
+ } from "../constants/keymaps.ts";
10
+ import type { AskState } from "../types.ts";
11
+
12
+ export type AskInputCommand =
13
+ | { kind: "moveTab"; delta: 1 | -1 }
14
+ | { kind: "moveOption"; delta: 1 | -1 }
15
+ | { kind: "toggleMulti" }
16
+ | { kind: "openQuestionNote" }
17
+ | { kind: "openOptionNote" }
18
+ | { kind: "confirm" }
19
+ | { kind: "cancel" }
20
+ | { kind: "changeQuestionType" }
21
+ | { kind: "dismiss" }
22
+ | { kind: "showSettings" }
23
+ | { kind: "numberShortcut"; digit: number }
24
+ | { kind: "editMoveTab"; delta: 1 | -1 }
25
+ | { kind: "editMoveOption"; delta: 1 | -1 }
26
+ | { kind: "editClose" }
27
+ | { kind: "editSubmit" }
28
+ | { kind: "delegateToEditor" }
29
+ | { kind: "ignore" };
30
+
31
+ export function getInputCommand(
32
+ state: AskState,
33
+ config: AskConfig,
34
+ data: string,
35
+ editingText = ""
36
+ ): AskInputCommand {
37
+ const global = getGlobalBindings(config);
38
+ if (matchesBinding(data, global.dismiss)) {
39
+ return { kind: "dismiss" };
40
+ }
41
+ if (matchesBinding(data, global.settings) && editingText.length === 0) {
42
+ return { kind: "showSettings" };
43
+ }
44
+
45
+ if (state.view.kind === "input") {
46
+ return getAnswerEditorInputCommand(config, data, editingText);
47
+ }
48
+ if (state.view.kind === "note") {
49
+ return getNoteEditorInputCommand(config, data, editingText);
50
+ }
51
+
52
+ return getNavigationInputCommand(config, data);
53
+ }
54
+
55
+ function getAnswerEditorInputCommand(
56
+ config: AskConfig,
57
+ data: string,
58
+ editingText: string
59
+ ): AskInputCommand {
60
+ const bindings = getAskContextBindings(config, "editor");
61
+ if (matchesBinding(data, bindings.submit)) {
62
+ return isNativeEditorSubmit(data)
63
+ ? { kind: "delegateToEditor" }
64
+ : { kind: "editSubmit" };
65
+ }
66
+ if (matchesBinding(data, bindings.close)) {
67
+ return { kind: "editClose" };
68
+ }
69
+ return getEmptyEditorNavigationCommand(bindings, data, editingText);
70
+ }
71
+
72
+ function getNoteEditorInputCommand(
73
+ config: AskConfig,
74
+ data: string,
75
+ editingText: string
76
+ ): AskInputCommand {
77
+ const bindings = getAskContextBindings(config, "noteEditor");
78
+ if (matchesBinding(data, bindings.save)) {
79
+ return isNativeEditorSubmit(data)
80
+ ? { kind: "delegateToEditor" }
81
+ : { kind: "editSubmit" };
82
+ }
83
+ if (matchesBinding(data, bindings.close)) {
84
+ return { kind: "editClose" };
85
+ }
86
+ return getEmptyEditorNavigationCommand(bindings, data, editingText);
87
+ }
88
+
89
+ function getEmptyEditorNavigationCommand(
90
+ bindings: {
91
+ nextTabWhenEmpty: AskKeyBinding;
92
+ previousTabWhenEmpty: AskKeyBinding;
93
+ previousOptionWhenEmpty: AskKeyBinding;
94
+ nextOptionWhenEmpty: AskKeyBinding;
95
+ },
96
+ data: string,
97
+ editingText: string
98
+ ): AskInputCommand {
99
+ if (editingText.length === 0) {
100
+ if (matchesBinding(data, bindings.nextTabWhenEmpty)) {
101
+ return { kind: "editMoveTab", delta: 1 };
102
+ }
103
+ if (matchesBinding(data, bindings.previousTabWhenEmpty)) {
104
+ return { kind: "editMoveTab", delta: -1 };
105
+ }
106
+ if (matchesBinding(data, bindings.previousOptionWhenEmpty)) {
107
+ return { kind: "editMoveOption", delta: -1 };
108
+ }
109
+ if (matchesBinding(data, bindings.nextOptionWhenEmpty)) {
110
+ return { kind: "editMoveOption", delta: 1 };
111
+ }
112
+ }
113
+ return { kind: "delegateToEditor" };
114
+ }
115
+
116
+ function isNativeEditorSubmit(data: string): boolean {
117
+ return matchesKey(data, "enter");
118
+ }
119
+
120
+ function getNavigationInputCommand(
121
+ config: AskConfig,
122
+ data: string
123
+ ): AskInputCommand {
124
+ const bindings = getAskContextBindings(config, "main");
125
+ if (matchesBinding(data, bindings.nextTab)) {
126
+ return { kind: "moveTab", delta: 1 };
127
+ }
128
+ if (matchesBinding(data, bindings.previousTab)) {
129
+ return { kind: "moveTab", delta: -1 };
130
+ }
131
+ if (matchesBinding(data, bindings.previousOption)) {
132
+ return { kind: "moveOption", delta: -1 };
133
+ }
134
+ if (matchesBinding(data, bindings.nextOption)) {
135
+ return { kind: "moveOption", delta: 1 };
136
+ }
137
+ if (matchesBinding(data, bindings.toggle)) {
138
+ return { kind: "toggleMulti" };
139
+ }
140
+ if (matchesBinding(data, bindings.changeQuestionType)) {
141
+ return { kind: "changeQuestionType" };
142
+ }
143
+ if (matchesBinding(data, bindings.confirm)) {
144
+ return { kind: "confirm" };
145
+ }
146
+ if (matchesBinding(data, bindings.cancel)) {
147
+ return { kind: "cancel" };
148
+ }
149
+ if (matchesBinding(data, bindings.questionNote)) {
150
+ return { kind: "openQuestionNote" };
151
+ }
152
+ if (matchesBinding(data, bindings.optionNote)) {
153
+ return { kind: "openOptionNote" };
154
+ }
155
+
156
+ const digit = matchesDigitShortcut(data);
157
+ return digit === null
158
+ ? { kind: "ignore" }
159
+ : { kind: "numberShortcut", digit };
160
+ }
@@ -0,0 +1,235 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { AskConfig } from "../config/schema.ts";
3
+ import {
4
+ getCurrentQuestion,
5
+ isQuestionAnswered,
6
+ isSubmitTab,
7
+ } from "../state/selectors.ts";
8
+ import { wrapText } from "../text.ts";
9
+ import type { AskState } from "../types.ts";
10
+ import { renderFooterText } from "./render-helpers.ts";
11
+ import type { Theme } from "./render-types.ts";
12
+
13
+ export function renderFrameHeader(args: {
14
+ lines: string[];
15
+ state: AskState;
16
+ theme: Theme;
17
+ width: number;
18
+ }) {
19
+ const { lines, state, theme, width } = args;
20
+ const add = (text = "") => lines.push(truncateToWidth(text, width));
21
+
22
+ add(theme.fg("accent", "─".repeat(Math.max(1, width))));
23
+ if (state.title) {
24
+ add(` ${theme.fg("accent", theme.bold(state.title))}`);
25
+ add();
26
+ }
27
+ add(renderTabs(state, theme, width));
28
+ add();
29
+ }
30
+
31
+ export function renderFrameFooter(args: {
32
+ config: AskConfig;
33
+ footerNotice?: string;
34
+ lines: string[];
35
+ state: AskState;
36
+ theme: Theme;
37
+ width: number;
38
+ }) {
39
+ const { config, footerNotice, lines, state, theme, width } = args;
40
+ const add = (text = "") => lines.push(truncateToWidth(text, width));
41
+ const footer = renderFooter(config, state, width);
42
+ const noticeLines = footerNotice ? wrapText(footerNotice, width) : [];
43
+ if (noticeLines.length > 0 || footer.length > 0) {
44
+ add();
45
+ for (const line of noticeLines) {
46
+ add(theme.fg("warning", line));
47
+ }
48
+ for (const line of footer) {
49
+ add(theme.fg("dim", line));
50
+ }
51
+ }
52
+ add(theme.fg("accent", "─".repeat(Math.max(1, width))));
53
+ }
54
+
55
+ const TAB_PREFIX = " ← ";
56
+ const TAB_SUFFIX = " →";
57
+ const TAB_SEPARATOR_WIDTH = visibleWidth(" ");
58
+
59
+ function renderTabs(state: AskState, theme: Theme, width: number): string {
60
+ const tabs = state.questions.map((question, index) => {
61
+ const active = state.activeTabIndex === index;
62
+ const answered = isQuestionAnswered(state, question.id);
63
+ const marker = answered ? "☒" : "☐";
64
+ const text = ` ${marker} ${question.label} `;
65
+ return {
66
+ text,
67
+ width: visibleWidth(text),
68
+ render: active
69
+ ? theme.bg("selectedBg", theme.fg("text", text))
70
+ : theme.fg(answered ? "success" : "muted", text),
71
+ };
72
+ });
73
+
74
+ const reviewText = " ☰ Review ";
75
+ tabs.push({
76
+ text: reviewText,
77
+ width: visibleWidth(reviewText),
78
+ render: isSubmitTab(state)
79
+ ? theme.bg("selectedBg", theme.fg("text", reviewText))
80
+ : theme.fg("success", reviewText),
81
+ });
82
+
83
+ const activeIndex = Math.min(state.activeTabIndex, tabs.length - 1);
84
+ const availableTabWidth = Math.max(
85
+ 1,
86
+ width - visibleWidth(TAB_PREFIX) - visibleWidth(TAB_SUFFIX)
87
+ );
88
+ const { start, end } = getVisibleTabRange(
89
+ tabs.map((tab) => tab.width),
90
+ activeIndex,
91
+ availableTabWidth
92
+ );
93
+ const leftArrow = theme.fg("dim", TAB_PREFIX.trimStart());
94
+ const rightArrow = theme.fg("dim", TAB_SUFFIX);
95
+ const visibleTabs = tabs
96
+ .slice(start, end + 1)
97
+ .map((tab) => tab.render)
98
+ .join(" ");
99
+ return truncateToWidth(
100
+ `${TAB_PREFIX[0]}${leftArrow}${visibleTabs}${rightArrow}`,
101
+ width
102
+ );
103
+ }
104
+
105
+ function getVisibleTabRange(
106
+ widths: number[],
107
+ activeIndex: number,
108
+ availableWidth: number
109
+ ): { start: number; end: number } {
110
+ if (widths.length === 0) {
111
+ return { start: 0, end: -1 };
112
+ }
113
+
114
+ const totalWidth =
115
+ widths.reduce((sum, width) => sum + width, 0) +
116
+ Math.max(0, widths.length - 1) * TAB_SEPARATOR_WIDTH;
117
+ if (totalWidth <= availableWidth) {
118
+ return { start: 0, end: widths.length - 1 };
119
+ }
120
+
121
+ const range = { start: activeIndex, end: activeIndex };
122
+ let usedWidth = widths[activeIndex] ?? 0;
123
+ let preferRight = activeIndex <= widths.length - activeIndex - 1;
124
+
125
+ while (true) {
126
+ const expansion = getExpandableTab(
127
+ widths,
128
+ range,
129
+ availableWidth,
130
+ usedWidth,
131
+ preferRight
132
+ );
133
+ if (!expansion) {
134
+ return range;
135
+ }
136
+ usedWidth += TAB_SEPARATOR_WIDTH + expansion.width;
137
+ applyTabGrowth(range, expansion.nextIndex, expansion.direction);
138
+ preferRight = !preferRight;
139
+ }
140
+ }
141
+
142
+ function getExpandableTab(
143
+ widths: number[],
144
+ range: { start: number; end: number },
145
+ availableWidth: number,
146
+ usedWidth: number,
147
+ preferRight: boolean
148
+ ):
149
+ | { direction: "left" | "right"; nextIndex: number; width: number }
150
+ | undefined {
151
+ for (const direction of getGrowthDirections(preferRight)) {
152
+ const nextIndex = getNextTabIndex(range, direction);
153
+ const nextWidth = widths[nextIndex];
154
+ if (
155
+ nextWidth === undefined ||
156
+ usedWidth + TAB_SEPARATOR_WIDTH + nextWidth > availableWidth
157
+ ) {
158
+ continue;
159
+ }
160
+ return { direction, nextIndex, width: nextWidth };
161
+ }
162
+ return;
163
+ }
164
+
165
+ function getGrowthDirections(preferRight: boolean): Array<"left" | "right"> {
166
+ return preferRight ? ["right", "left"] : ["left", "right"];
167
+ }
168
+
169
+ function getNextTabIndex(
170
+ range: { start: number; end: number },
171
+ direction: "left" | "right"
172
+ ): number {
173
+ return direction === "left" ? range.start - 1 : range.end + 1;
174
+ }
175
+
176
+ function applyTabGrowth(
177
+ range: { start: number; end: number },
178
+ nextIndex: number,
179
+ direction: "left" | "right"
180
+ ) {
181
+ if (direction === "left") {
182
+ range.start = nextIndex;
183
+ return;
184
+ }
185
+ range.end = nextIndex;
186
+ }
187
+
188
+ function renderFooter(
189
+ config: AskConfig,
190
+ state: AskState,
191
+ width: number
192
+ ): string[] {
193
+ if (!config.behaviour.showFooterHints) {
194
+ return [];
195
+ }
196
+ let footer: string;
197
+ if (state.view.kind === "input") {
198
+ footer = renderFooterText(config, "input");
199
+ } else if (state.view.kind === "note") {
200
+ footer = renderFooterText(config, "note");
201
+ } else if (isSubmitTab(state)) {
202
+ footer = renderFooterText(config, "submit");
203
+ } else {
204
+ const question = getCurrentQuestion(state);
205
+ footer = renderFooterText(
206
+ config,
207
+ question?.type === "multi" ? "multi" : "default"
208
+ );
209
+ }
210
+ return wrapDelimitedFooterHints(footer, width);
211
+ }
212
+
213
+ function wrapDelimitedFooterHints(footer: string, width: number): string[] {
214
+ return footer.split(" · ").reduce<string[]>((lines, chunk) => {
215
+ const current = lines.at(-1) ?? "";
216
+ const next = current ? `${current} · ${chunk}` : chunk;
217
+ const fitsCurrentLine = visibleWidth(next) <= width;
218
+ if (fitsCurrentLine) {
219
+ if (current) {
220
+ lines[lines.length - 1] = next;
221
+ } else {
222
+ lines.push(next);
223
+ }
224
+ return lines;
225
+ }
226
+
227
+ const wrappedChunk = wrapText(chunk, width);
228
+ if (!current) {
229
+ lines.push(...wrappedChunk);
230
+ return lines;
231
+ }
232
+ lines.push(...wrappedChunk);
233
+ return lines;
234
+ }, []);
235
+ }