From dac9e7af59bdef51030248f5cc90ce79cb587f51 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Sun, 6 Sep 2026 22:28:57 +0200 Subject: [PATCH] chore: improved tui wizard keybindings --- src/presentation/tui/wizard/Wizard.tsx | 2 +- .../tui/wizard/WizardConstants.ts | 2 +- .../tui/wizard/WizardTextInput.tsx | 73 +++++++++--- .../tui/goals/GoalAuthoringFlow.test.tsx | 14 +-- .../project-initialization/InitFlow.test.tsx | 14 +-- tests/presentation/tui/wizard/Wizard.test.tsx | 34 +++++- .../tui/wizard/WizardTextInput.test.tsx | 105 +++++++++++++++++- 7 files changed, 206 insertions(+), 38 deletions(-) diff --git a/src/presentation/tui/wizard/Wizard.tsx b/src/presentation/tui/wizard/Wizard.tsx index 7f5ce39f..ff813614 100644 --- a/src/presentation/tui/wizard/Wizard.tsx +++ b/src/presentation/tui/wizard/Wizard.tsx @@ -282,7 +282,7 @@ export function Wizard({ onBack?.(); }; - if (key.leftArrow && !input) { + if (key.ctrl && input === "b") { navigateBack(); return; } diff --git a/src/presentation/tui/wizard/WizardConstants.ts b/src/presentation/tui/wizard/WizardConstants.ts index 40e3b493..7dd3e76b 100644 --- a/src/presentation/tui/wizard/WizardConstants.ts +++ b/src/presentation/tui/wizard/WizardConstants.ts @@ -23,7 +23,7 @@ export const WizardKeyboardHintCopy = { } as const; export const WizardKeyboardHintKey = { - back: "←", + back: "ctrl+b", submit: "⏎", cancel: "esc", field: "↑↓", diff --git a/src/presentation/tui/wizard/WizardTextInput.tsx b/src/presentation/tui/wizard/WizardTextInput.tsx index 6932bd20..288f8888 100644 --- a/src/presentation/tui/wizard/WizardTextInput.tsx +++ b/src/presentation/tui/wizard/WizardTextInput.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useLayoutEffect, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { SemanticColors, TuiGlyphs } from "../../shared/DesignTokens.js"; @@ -22,35 +22,75 @@ export function WizardTextInput({ focused = true, error, }: WizardTextInputProps): React.ReactElement { + const [cursorPosition, setCursorPosition] = useState( + Array.from(value).length, + ); + const editingRef = useRef({ value, cursorPosition }); + + useLayoutEffect(() => { + if (value !== editingRef.current.value) { + const nextPosition = Array.from(value).length; + editingRef.current = { value, cursorPosition: nextPosition }; + setCursorPosition(nextPosition); + } + }, [value]); + useInput( (input, key) => { - if (key.backspace || key.delete) { - if (value.length > 0) { - onChange(value.slice(0, -1)); + const characters = Array.from(editingRef.current.value); + const position = editingRef.current.cursorPosition; + const moveCursor = (nextPosition: number) => { + editingRef.current.cursorPosition = nextPosition; + setCursorPosition(nextPosition); + }; + const changeValue = (nextPosition: number) => { + const nextValue = characters.join(""); + editingRef.current = { value: nextValue, cursorPosition: nextPosition }; + setCursorPosition(nextPosition); + onChange(nextValue); + }; + + if (key.leftArrow) { + moveCursor(Math.max(0, position - 1)); + return; + } + + if (key.rightArrow) { + moveCursor(Math.min(characters.length, position + 1)); + return; + } + + if (key.backspace) { + if (position > 0) { + characters.splice(position - 1, 1); + changeValue(position - 1); + } + return; + } + + if (key.delete) { + if (position < characters.length) { + characters.splice(position, 1); + changeValue(position); } return; } - if ( - key.return || - key.tab || - key.escape || - key.upArrow || - key.downArrow || - key.leftArrow || - key.rightArrow - ) { + if (key.return || key.tab || key.escape || key.upArrow || key.downArrow) { return; } if (input && !key.ctrl && !key.meta) { - onChange(value + input); + const insertedCharacters = Array.from(input); + characters.splice(position, 0, ...insertedCharacters); + changeValue(position + insertedCharacters.length); } }, { isActive: focused }, ); const showPlaceholder = value.length === 0 && placeholder !== undefined; + const characters = Array.from(value); return ( @@ -83,8 +123,9 @@ export function WizardTextInput({ color={SemanticColors.inputText} backgroundColor={INPUT_BACKGROUND} > - {value} - {focused && "▎"} + {focused + ? `${characters.slice(0, cursorPosition).join("")}▎${characters.slice(cursorPosition).join("")}` + : value} )} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx index 1ef472a3..6ae519be 100644 --- a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx +++ b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx @@ -15,7 +15,7 @@ import { import { WizardValidationCopy } from "../../../../src/presentation/tui/wizard/WizardConstants.js"; const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); -const LEFT_ARROW = "\x1B[D"; +const CTRL_B = "\x02"; const SUCCESSFUL_SUBMISSION: GoalAuthoringSubmissionResult = { status: GoalAuthoringRequestStatus.SUCCESS, goalId: "goal_created", @@ -344,11 +344,11 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); expect(lastFrame()).toContain("src/application layer"); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); expect(lastFrame()).toContain("src/presentation tui"); @@ -363,17 +363,17 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame(lastFrame, (frame) => frame.includes("Success criterion"), ); expect(lastFrame()).toContain("Renders goals"); - stdin.write(LEFT_ARROW); + stdin.write(CTRL_B); await waitForFrame( lastFrame, (frame) => frame.includes("Title") && frame.includes("Objective"), diff --git a/tests/presentation/tui/project-initialization/InitFlow.test.tsx b/tests/presentation/tui/project-initialization/InitFlow.test.tsx index 7672b937..75f613ce 100644 --- a/tests/presentation/tui/project-initialization/InitFlow.test.tsx +++ b/tests/presentation/tui/project-initialization/InitFlow.test.tsx @@ -97,7 +97,7 @@ describe("InitFlow", () => { expect(lastFrame()).toContain("Add an audience?"); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); expect(lastFrame()).toContain("Project purpose"); @@ -153,7 +153,7 @@ describe("InitFlow", () => { expect(lastFrame()).toContain("Audience name"); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); expect(lastFrame()).toContain("Add an audience?"); @@ -180,7 +180,7 @@ describe("InitFlow", () => { expect(lastFrame()).toContain("Value proposition title"); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); expect(lastFrame()).toContain("Add a value proposition?"); @@ -240,9 +240,9 @@ describe("InitFlow", () => { await tick(); stdin.write("\r"); await tick(); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); stdin.write("\r"); await tick(); @@ -327,9 +327,9 @@ describe("InitFlow", () => { frame.includes("Proceed with initialization?"), ); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); - stdin.write("\x1B[D"); + stdin.write("\x02"); await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => diff --git a/tests/presentation/tui/wizard/Wizard.test.tsx b/tests/presentation/tui/wizard/Wizard.test.tsx index 6d30e588..5949e10e 100644 --- a/tests/presentation/tui/wizard/Wizard.test.tsx +++ b/tests/presentation/tui/wizard/Wizard.test.tsx @@ -314,7 +314,7 @@ describe("Wizard", () => { expect(lastFrame()).not.toContain(WizardKeyboardHintCopy.back); }); - it("shows left-arrow back hint when parent back is available", () => { + it("shows Ctrl+B back hint when parent back is available", () => { const { lastFrame } = render( { expect(lastFrame()).toContain(WizardKeyboardHintCopy.back); }); - it("calls parent back handler from the first step", async () => { + it("calls parent back handler with Ctrl+B but not Left Arrow", async () => { const handleBack = jest.fn(); const { stdin } = render( { ); stdin.write("\x1B[D"); await tick(); + expect(handleBack).not.toHaveBeenCalled(); + + stdin.write("\x02"); + await tick(); expect(handleBack).toHaveBeenCalledTimes(1); }); @@ -479,12 +483,13 @@ describe("Wizard", () => { expect(lastFrame()).toContain("Smith"); }); - it("shows left-arrow back hint on second step when focused field is text", async () => { + it("navigates back with Ctrl+B but not Left Arrow while preserving text", async () => { + const handleConfirm = jest.fn(); const { lastFrame, stdin } = render( {}} + onConfirm={handleConfirm} onCancel={() => {}} />, ); @@ -494,6 +499,27 @@ describe("Wizard", () => { await tick(); expect(lastFrame()).toContain(WizardKeyboardHintKey.back); expect(lastFrame()).toContain(WizardKeyboardHintCopy.back); + expect(lastFrame()).toContain("ctrl+b"); + + stdin.write("alice@example.com"); + await tick(); + stdin.write("\x1B[D"); + await tick(); + expect(lastFrame()).toContain("2/2"); + + stdin.write("\x02"); + await tick(); + expect(lastFrame()).toContain("1/2"); + expect(lastFrame()).toContain("Alice"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + expect(handleConfirm).toHaveBeenCalledWith({ + name: "Alice", + email: "alice@example.com", + }); }); it("uses a supplied progress label instead of local step count", () => { diff --git a/tests/presentation/tui/wizard/WizardTextInput.test.tsx b/tests/presentation/tui/wizard/WizardTextInput.test.tsx index 44a78f24..c0c080f4 100644 --- a/tests/presentation/tui/wizard/WizardTextInput.test.tsx +++ b/tests/presentation/tui/wizard/WizardTextInput.test.tsx @@ -1,9 +1,110 @@ -import React from "react"; -import { describe, expect, it } from "@jest/globals"; +import React, { useState } from "react"; +import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; import { WizardTextInput } from "../../../../src/presentation/tui/wizard/WizardTextInput.js"; +const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); +const LEFT_ARROW = "\x1B[D"; +const RIGHT_ARROW = "\x1B[C"; +const BACKSPACE = "\x7f"; +const DELETE = "\x1B[3~"; + +function EditableInput({ initialValue }: { initialValue: string }) { + const [value, setValue] = useState(initialValue); + return ; +} + describe("WizardTextInput", () => { + it("inserts text and pasted text at the cursor after moving left and right", async () => { + const { stdin, lastFrame, unmount } = render( + , + ); + await tick(); + for (const key of [LEFT_ARROW, LEFT_ARROW, "XY", RIGHT_ARROW, "!"]) { + stdin.write(key); + await tick(); + } + expect(lastFrame()).toContain("abXYc!▎d"); + unmount(); + }); + + it("bounds the cursor and deletes before or after it without corrupting emoji", async () => { + const { stdin, lastFrame, unmount } = render( + , + ); + await tick(); + for (const key of [RIGHT_ARROW, DELETE, LEFT_ARROW, BACKSPACE]) { + stdin.write(key); + await tick(); + } + expect(lastFrame()).toContain("A▎B"); + for (const key of [LEFT_ARROW, LEFT_ARROW, BACKSPACE, DELETE]) { + stdin.write(key); + await tick(); + } + expect(lastFrame()).toContain("▎B"); + stdin.write(DELETE); + await tick(); + stdin.write(DELETE); + await tick(); + stdin.write("new"); + await tick(); + expect(lastFrame()).toContain("new▎"); + unmount(); + }); + + it("synchronizes the cursor when a caller replaces the value", async () => { + const onChange = jest.fn(); + const { stdin, lastFrame, rerender, unmount } = render( + , + ); + await tick(); + stdin.write(LEFT_ARROW); + await tick(); + rerender(); + await tick(); + expect(lastFrame()).toContain("new▎"); + stdin.write("!"); + await tick(); + expect(onChange).toHaveBeenLastCalledWith("new!"); + unmount(); + }); + + it("ignores editing input when unfocused and restores the cursor on refocus", async () => { + const onChange = jest.fn(); + const { stdin, lastFrame, rerender, unmount } = render( + , + ); + await tick(); + stdin.write(LEFT_ARROW); + await tick(); + rerender( + , + ); + await tick(); + for (const key of [LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE, "x"]) { + stdin.write(key); + await tick(); + } + expect(onChange).not.toHaveBeenCalled(); + expect(lastFrame()).not.toContain("▎"); + rerender(); + await tick(); + expect(lastFrame()).toContain("ab▎c"); + stdin.write("\x02"); + await tick(); + expect(onChange).not.toHaveBeenCalled(); + stdin.write("!"); + await tick(); + expect(onChange).toHaveBeenLastCalledWith("ab!c"); + unmount(); + }); + it("renders the label", () => { const { lastFrame } = render( {}} />,