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(
{}} />,