Skip to content

Commit 090da6d

Browse files
fix: persist setup wizard choice commits (#34)
1 parent 7a438e0 commit 090da6d

3 files changed

Lines changed: 219 additions & 55 deletions

File tree

src/setup-ui-keys.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { isSubmitKey, parseChoiceShortcut } from "./setup-ui-keys.js";
3+
4+
describe("setup UI key handling", () => {
5+
test("accepts common submit key names", () => {
6+
expect(isSubmitKey("return")).toBe(true);
7+
expect(isSubmitKey("linefeed")).toBe(true);
8+
expect(isSubmitKey("enter")).toBe(true);
9+
expect(isSubmitKey("space")).toBe(false);
10+
});
11+
12+
test("maps numeric shortcuts to zero-based choice indexes", () => {
13+
expect(parseChoiceShortcut("1")).toBe(0);
14+
expect(parseChoiceShortcut("2")).toBe(1);
15+
expect(parseChoiceShortcut("9")).toBe(8);
16+
expect(parseChoiceShortcut("0")).toBeUndefined();
17+
expect(parseChoiceShortcut("x")).toBeUndefined();
18+
});
19+
});

src/setup-ui-keys.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function isSubmitKey(name: string): boolean {
2+
return name === "return" || name === "linefeed" || name === "enter";
3+
}
4+
5+
export function parseChoiceShortcut(name: string): number | undefined {
6+
if (!/^[1-9]$/.test(name)) {
7+
return undefined;
8+
}
9+
10+
return Number.parseInt(name, 10) - 1;
11+
}

src/setup-ui.tsx

Lines changed: 189 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createCliRenderer } from "@opentui/core";
22
import { render, useKeyboard } from "@opentui/solid";
3-
import { createMemo, createSignal, For } from "solid-js";
3+
import { createEffect, createMemo, createSignal, For } from "solid-js";
44
import {
55
applySetupState,
66
type SetupContext,
@@ -9,6 +9,7 @@ import {
99
type SetupState,
1010
summarizeSetupContext,
1111
} from "./setup-core.js";
12+
import { isSubmitKey, parseChoiceShortcut } from "./setup-ui-keys.js";
1213
import { getNextWizardStepIndex } from "./setup-ui-state.js";
1314

1415
type WizardChoice = {
@@ -357,6 +358,7 @@ export function SetupWizard(props: {
357358
);
358359
const [errorMessage, setErrorMessage] = createSignal<string>();
359360
const [result, setResult] = createSignal<SetupResult>();
361+
const [summaryActionIndex, setSummaryActionIndex] = createSignal(0);
360362

361363
const steps = createMemo(() => buildSteps({ ...state() }));
362364
const activeStep = createMemo(() => steps()[Math.min(stepIndex(), steps().length - 1)]);
@@ -377,6 +379,12 @@ export function SetupWizard(props: {
377379
return index >= 0 ? index : 0;
378380
});
379381

382+
createEffect(() => {
383+
if (activeStep().kind === "summary") {
384+
setSummaryActionIndex(0);
385+
}
386+
});
387+
380388
const goBack = (): void => {
381389
if (phase() === "saving") {
382390
return;
@@ -436,6 +444,68 @@ export function SetupWizard(props: {
436444
}
437445
};
438446

447+
const updateChoiceSelection = (offset: -1 | 1): void => {
448+
const step = activeChoiceStep();
449+
if (!step) {
450+
return;
451+
}
452+
453+
const nextIndex = Math.max(0, Math.min(activeChoiceIndex() + offset, step.options.length - 1));
454+
const option = step.options[nextIndex];
455+
if (!option) {
456+
return;
457+
}
458+
459+
setState((current) => {
460+
const next = { ...current };
461+
step.commit(next, option.value);
462+
return next;
463+
});
464+
};
465+
466+
const commitChoiceAndAdvance = (): void => {
467+
const step = activeChoiceStep();
468+
if (!step) {
469+
return;
470+
}
471+
472+
const option = step.options[activeChoiceIndex()];
473+
if (!option) {
474+
return;
475+
}
476+
477+
commitAndAdvance(() => {
478+
setState((current) => {
479+
const next = { ...current };
480+
step.commit(next, option.value);
481+
return next;
482+
});
483+
});
484+
};
485+
486+
const triggerSummaryAction = (): void => {
487+
if (summaryActionIndex() === 1) {
488+
props.cancel(new Error("Setup cancelled."));
489+
return;
490+
}
491+
492+
void save();
493+
};
494+
495+
const triggerSummaryShortcut = (index: number): void => {
496+
if (index < 0 || index > 1) {
497+
return;
498+
}
499+
500+
setSummaryActionIndex(index);
501+
if (index === 1) {
502+
props.cancel(new Error("Setup cancelled."));
503+
return;
504+
}
505+
506+
void save();
507+
};
508+
439509
useKeyboard(
440510
(event: {
441511
ctrl: boolean;
@@ -457,7 +527,81 @@ export function SetupWizard(props: {
457527
return;
458528
}
459529

460-
if ((phase() === "done" || phase() === "error") && event.name === "return") {
530+
if (phase() === "wizard" && activeStep().kind === "choice") {
531+
const shortcutIndex = parseChoiceShortcut(event.name);
532+
if (shortcutIndex !== undefined) {
533+
const step = activeChoiceStep();
534+
const option = step?.options[shortcutIndex];
535+
if (!option || !step) {
536+
return;
537+
}
538+
539+
event.preventDefault();
540+
event.stopPropagation();
541+
commitAndAdvance(() => {
542+
setState((current) => {
543+
const next = { ...current };
544+
step.commit(next, option.value);
545+
return next;
546+
});
547+
});
548+
return;
549+
}
550+
551+
if (event.name === "left") {
552+
event.preventDefault();
553+
event.stopPropagation();
554+
updateChoiceSelection(-1);
555+
return;
556+
}
557+
558+
if (event.name === "right") {
559+
event.preventDefault();
560+
event.stopPropagation();
561+
updateChoiceSelection(1);
562+
return;
563+
}
564+
565+
if (isSubmitKey(event.name)) {
566+
event.preventDefault();
567+
event.stopPropagation();
568+
commitChoiceAndAdvance();
569+
return;
570+
}
571+
}
572+
573+
if (phase() === "wizard" && activeStep().kind === "summary") {
574+
const shortcutIndex = parseChoiceShortcut(event.name);
575+
if (shortcutIndex !== undefined) {
576+
event.preventDefault();
577+
event.stopPropagation();
578+
triggerSummaryShortcut(shortcutIndex);
579+
return;
580+
}
581+
582+
if (event.name === "left") {
583+
event.preventDefault();
584+
event.stopPropagation();
585+
setSummaryActionIndex(0);
586+
return;
587+
}
588+
589+
if (event.name === "right") {
590+
event.preventDefault();
591+
event.stopPropagation();
592+
setSummaryActionIndex(1);
593+
return;
594+
}
595+
596+
if (isSubmitKey(event.name)) {
597+
event.preventDefault();
598+
event.stopPropagation();
599+
triggerSummaryAction();
600+
return;
601+
}
602+
}
603+
604+
if ((phase() === "done" || phase() === "error") && isSubmitKey(event.name)) {
461605
event.preventDefault();
462606
event.stopPropagation();
463607
goBack();
@@ -554,30 +698,26 @@ export function SetupWizard(props: {
554698
{(line) => <text fg="#d7e3ea">{line}</text>}
555699
</For>
556700
</box>
557-
<tab_select
558-
focused
559-
options={[
560-
{
561-
name: "Save",
562-
description: "Write config and finish setup.",
563-
value: "save",
564-
},
565-
{
566-
name: "Cancel",
567-
description: "Exit without writing files.",
568-
value: "cancel",
569-
},
570-
]}
571-
selectedIndex={0}
572-
showDescription
573-
onSelect={(_index: number, option: WizardChoice | null) => {
574-
if (option?.value === "cancel") {
575-
props.cancel(new Error("Setup cancelled."));
576-
return;
577-
}
578-
void save();
579-
}}
580-
/>
701+
<box flexDirection="column" gap={1}>
702+
<box>
703+
<For each={["Save", "Cancel"]}>
704+
{(label, index) => (
705+
<text
706+
backgroundColor={index() === summaryActionIndex() ? "#334455" : "#1a1a1a"}
707+
fg={index() === summaryActionIndex() ? "#ffff00" : "#ffffff"}
708+
>
709+
{" "}
710+
{label}{" "}
711+
</text>
712+
)}
713+
</For>
714+
</box>
715+
<text fg="#cccccc">
716+
{summaryActionIndex() === 0
717+
? "Write config and finish setup."
718+
: "Exit without writing files."}
719+
</text>
720+
</box>
581721
</box>
582722
) : (
583723
(() => {
@@ -591,34 +731,26 @@ export function SetupWizard(props: {
591731
</text>
592732
<text fg="#d7e3ea">{choiceStep.description}</text>
593733
<text fg="#7d91a2">{choiceStep.hint}</text>
594-
<tab_select
595-
focused
596-
options={choiceStep.options}
597-
selectedIndex={activeChoiceIndex()}
598-
showDescription
599-
onChange={(_index: number, option: WizardChoice | null) => {
600-
if (!option) {
601-
return;
602-
}
603-
setState((current) => {
604-
const next = { ...current };
605-
choiceStep.commit(next, option.value);
606-
return next;
607-
});
608-
}}
609-
onSelect={(_index: number, option: WizardChoice | null) => {
610-
if (!option) {
611-
return;
612-
}
613-
commitAndAdvance(() => {
614-
setState((current) => {
615-
const next = { ...current };
616-
choiceStep.commit(next, option.value);
617-
return next;
618-
});
619-
});
620-
}}
621-
/>
734+
<box flexDirection="column" gap={1}>
735+
<box>
736+
<For each={choiceStep.options}>
737+
{(option, index) => (
738+
<text
739+
backgroundColor={
740+
index() === activeChoiceIndex() ? "#334455" : "#1a1a1a"
741+
}
742+
fg={index() === activeChoiceIndex() ? "#ffff00" : "#ffffff"}
743+
>
744+
{" "}
745+
{option.name}{" "}
746+
</text>
747+
)}
748+
</For>
749+
</box>
750+
<text fg="#cccccc">
751+
{choiceStep.options[activeChoiceIndex()]?.description ?? ""}
752+
</text>
753+
</box>
622754
</box>
623755
);
624756
}
@@ -713,7 +845,9 @@ export function SetupWizard(props: {
713845
) : null}
714846

715847
<box marginTop="auto" border borderColor="#1d313a" padding={1}>
716-
<text fg="#7d91a2">Esc goes back. Ctrl+C exits setup immediately.</text>
848+
<text fg="#7d91a2">
849+
Esc goes back. Ctrl+C exits setup immediately. 1-9 selects a visible choice.
850+
</text>
717851
</box>
718852
</box>
719853
</box>

0 commit comments

Comments
 (0)