fix(ask): adjust text input behavior

This commit is contained in:
Erik Kotsiuba
2026-04-17 12:38:32 +02:00
parent a212997786
commit 49f25d77be
3 changed files with 139 additions and 18 deletions
+134 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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",
); );
}); });