@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
package/src/ui/input.ts
ADDED
|
@@ -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
|
+
}
|