fix(ask): adjust text input behavior
This commit is contained in:
+134
-15
@@ -5,6 +5,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
|
visibleWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
allRequiredAnswered,
|
allRequiredAnswered,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
cancelFlow,
|
cancelFlow,
|
||||||
confirmCurrentSelection,
|
confirmCurrentSelection,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
enterInputMode,
|
||||||
|
exitInputMode,
|
||||||
getAnswer,
|
getAnswer,
|
||||||
getCurrentQuestion,
|
getCurrentQuestion,
|
||||||
getRenderableOptions,
|
getRenderableOptions,
|
||||||
@@ -29,9 +32,14 @@ export async function runAskFlow(
|
|||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
params: AskParams,
|
params: AskParams,
|
||||||
): Promise<AskResult> {
|
): Promise<AskResult> {
|
||||||
return ctx.ui.custom<AskResult>((tui, theme, _keybindings, done) => {
|
return ctx.ui.custom<AskResult>((tui, theme, keybindings, done) => {
|
||||||
let state: AskState = createInitialState(params);
|
let state: AskState = createInitialState(params);
|
||||||
let cachedLines: string[] | undefined;
|
let cachedLines: string[] | undefined;
|
||||||
|
let inputEscapePending = false;
|
||||||
|
let suppressAutoInputForSelection = false;
|
||||||
|
const newLineHint = formatKeybindingLabel(
|
||||||
|
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
|
||||||
|
);
|
||||||
|
|
||||||
const editorTheme: EditorTheme = {
|
const editorTheme: EditorTheme = {
|
||||||
borderColor: (s) => theme.fg("accent", s),
|
borderColor: (s) => theme.fg("accent", s),
|
||||||
@@ -46,6 +54,8 @@ export async function runAskFlow(
|
|||||||
const editor = new Editor(tui, editorTheme);
|
const editor = new Editor(tui, editorTheme);
|
||||||
|
|
||||||
editor.onSubmit = (value) => {
|
editor.onSubmit = (value) => {
|
||||||
|
inputEscapePending = false;
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
state = submitCustomAnswer(state, value);
|
state = submitCustomAnswer(state, value);
|
||||||
editor.setText("");
|
editor.setText("");
|
||||||
refresh();
|
refresh();
|
||||||
@@ -63,52 +73,131 @@ export async function runAskFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateEditorForInputMode() {
|
||||||
|
if (state.mode !== "input" || !state.inputQuestionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingText =
|
||||||
|
getAnswer(state, state.inputQuestionId)?.customText ?? "";
|
||||||
|
editor.setText(existingText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncInputModeWithSelection() {
|
||||||
|
if (isSubmitTab(state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = getCurrentQuestion(state);
|
||||||
|
if (!question) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = getRenderableOptions(question)[state.optionIndex];
|
||||||
|
if (
|
||||||
|
!option?.isOther ||
|
||||||
|
suppressAutoInputForSelection ||
|
||||||
|
state.mode === "input"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = enterInputMode(state, question.id);
|
||||||
|
inputEscapePending = false;
|
||||||
|
hydrateEditorForInputMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInputModeWithSelection();
|
||||||
|
|
||||||
function handleInput(data: string) {
|
function handleInput(data: string) {
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
state = exitInputMode(state);
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
|
state = moveTab(state, 1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
state = exitInputMode(state);
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
|
state = moveTab(state, -1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (matchesKey(data, Key.escape)) {
|
if (matchesKey(data, Key.escape)) {
|
||||||
state = cancelFlow(state);
|
if (editor.getText().trim().length === 0 || inputEscapePending) {
|
||||||
if (!state.completed) {
|
state = exitInputMode(state);
|
||||||
|
suppressAutoInputForSelection = true;
|
||||||
|
inputEscapePending = false;
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
maybeFinish();
|
inputEscapePending = true;
|
||||||
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
inputEscapePending = false;
|
||||||
editor.handleInput(data);
|
editor.handleInput(data);
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = moveTab(state, 1);
|
state = moveTab(state, 1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.up)) {
|
if (matchesKey(data, Key.up)) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = moveOption(state, -1);
|
state = moveOption(state, -1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.down)) {
|
if (matchesKey(data, Key.down)) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = moveOption(state, 1);
|
state = moveOption(state, 1);
|
||||||
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.space)) {
|
if (matchesKey(data, Key.space)) {
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
if (question?.type === "multi") {
|
if (question?.type === "multi") {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = toggleCurrentMultiOption(state);
|
state = toggleCurrentMultiOption(state);
|
||||||
|
if (state.mode === "input") {
|
||||||
|
hydrateEditorForInputMode();
|
||||||
|
}
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.enter)) {
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = confirmCurrentSelection(state);
|
state = confirmCurrentSelection(state);
|
||||||
|
if (state.mode === "input") {
|
||||||
|
hydrateEditorForInputMode();
|
||||||
|
}
|
||||||
refresh();
|
refresh();
|
||||||
maybeFinish();
|
maybeFinish();
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +211,12 @@ export async function runAskFlow(
|
|||||||
|
|
||||||
const digit = parseDigit(data);
|
const digit = parseDigit(data);
|
||||||
if (digit !== null) {
|
if (digit !== null) {
|
||||||
|
suppressAutoInputForSelection = false;
|
||||||
|
inputEscapePending = false;
|
||||||
state = applyNumberShortcut(state, digit);
|
state = applyNumberShortcut(state, digit);
|
||||||
|
if (state.mode === "input") {
|
||||||
|
hydrateEditorForInputMode();
|
||||||
|
}
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -176,20 +270,22 @@ export async function runAskFlow(
|
|||||||
}
|
}
|
||||||
add();
|
add();
|
||||||
}
|
}
|
||||||
}
|
if (option.isOther && selected && state.mode === "input") {
|
||||||
|
for (const editorLine of editor.render(Math.max(8, width - 7))) {
|
||||||
if (state.mode === "input") {
|
add(` ${renderInputLine(editorLine, width - 5, theme)}`);
|
||||||
add();
|
}
|
||||||
add(` ${theme.fg("muted", "Your answer:")}`);
|
add(
|
||||||
for (const editorLine of editor.render(Math.max(1, width - 2))) {
|
` ${theme.fg("dim", inputEscapePending ? `${newLineHint} newline · Enter submit · Esc again to go back` : `${newLineHint} newline · Enter submit · Esc to go back if empty`)}`,
|
||||||
add(` ${editorLine}`);
|
);
|
||||||
}
|
}
|
||||||
add();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add();
|
const footer = renderFooter(state, theme);
|
||||||
add(renderFooter(state, theme));
|
if (footer) {
|
||||||
|
add();
|
||||||
|
add(footer);
|
||||||
|
}
|
||||||
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
|
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
|
||||||
|
|
||||||
cachedLines = lines;
|
cachedLines = lines;
|
||||||
@@ -276,7 +372,7 @@ function renderFooter(
|
|||||||
theme: ExtensionContext["ui"]["theme"],
|
theme: ExtensionContext["ui"]["theme"],
|
||||||
): string {
|
): string {
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
return theme.fg("dim", " Enter submit · Esc go back");
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
@@ -298,3 +394,26 @@ function renderFooter(
|
|||||||
function parseDigit(data: string): number | null {
|
function parseDigit(data: string): number | null {
|
||||||
return /^[1-9]$/.test(data) ? Number(data) : null;
|
return /^[1-9]$/.test(data) ? Number(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInputLine(
|
||||||
|
line: string,
|
||||||
|
availableWidth: number,
|
||||||
|
theme: ExtensionContext["ui"]["theme"],
|
||||||
|
): string {
|
||||||
|
const innerWidth = Math.max(4, availableWidth - 2);
|
||||||
|
const truncated = truncateToWidth(line, innerWidth);
|
||||||
|
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
||||||
|
return theme.bg("toolPendingBg", ` ${truncated}${padding} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKeybindingLabel(key: string): string {
|
||||||
|
return key
|
||||||
|
.split("+")
|
||||||
|
.map((part) => {
|
||||||
|
if (part.length <= 1) {
|
||||||
|
return part.toUpperCase();
|
||||||
|
}
|
||||||
|
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||||
|
})
|
||||||
|
.join("+");
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -53,12 +53,14 @@ export function isSubmitTab(state: AskState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
|
export function getRenderableOptions(question?: AskQuestion): RenderOption[] {
|
||||||
if (!question) return [];
|
if (!question) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const options: RenderOption[] = [...question.options];
|
const options: RenderOption[] = [...question.options];
|
||||||
if (question.allowOther) {
|
if (question.allowOther) {
|
||||||
options.push({
|
options.push({
|
||||||
value: OTHER_VALUE,
|
value: OTHER_VALUE,
|
||||||
label: "Type something.",
|
label: "Type your own",
|
||||||
isOther: true,
|
isOther: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ test("normalize defaults via initial state", () => {
|
|||||||
assert.equal(state.questions[0].required, true);
|
assert.equal(state.questions[0].required, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getRenderableOptions(state.questions[0]).at(-1)?.label,
|
getRenderableOptions(state.questions[0]).at(-1)?.label,
|
||||||
"Type something.",
|
"Type your own",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user