Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/presentation/tui/wizard/Wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function Wizard({
onBack?.();
};

if (key.leftArrow && !input) {
if (key.ctrl && input === "b") {
navigateBack();
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/presentation/tui/wizard/WizardConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const WizardKeyboardHintCopy = {
} as const;

export const WizardKeyboardHintKey = {
back: "",
back: "ctrl+b",
submit: "⏎",
cancel: "esc",
field: "↑↓",
Expand Down
73 changes: 57 additions & 16 deletions src/presentation/tui/wizard/WizardTextInput.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<Box flexDirection="column" gap={0}>
Expand Down Expand Up @@ -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}
</Text>
)}
</Box>
Expand Down
14 changes: 7 additions & 7 deletions tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");

Expand All @@ -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"),
Expand Down
14 changes: 7 additions & 7 deletions tests/presentation/tui/project-initialization/InitFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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?");
Expand All @@ -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?");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) =>
Expand Down
34 changes: 30 additions & 4 deletions tests/presentation/tui/wizard/Wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Wizard
title="Setup"
Expand Down Expand Up @@ -342,7 +342,7 @@ describe("Wizard", () => {
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(
<Wizard
Expand All @@ -355,6 +355,10 @@ describe("Wizard", () => {
);
stdin.write("\x1B[D");
await tick();
expect(handleBack).not.toHaveBeenCalled();

stdin.write("\x02");
await tick();
expect(handleBack).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -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(
<Wizard
title="Setup"
steps={TWO_STEP_CONFIG}
onConfirm={() => {}}
onConfirm={handleConfirm}
onCancel={() => {}}
/>,
);
Expand All @@ -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", () => {
Expand Down
Loading
Loading