Skip to content

Commit 7a438e0

Browse files
fix: harden setup tui rendering (#33)
* fix: harden setup tui rendering * docs: clarify setup tui exit controls
1 parent 3fd43f4 commit 7a438e0

File tree

3 files changed

+139
-117
lines changed

3 files changed

+139
-117
lines changed

.github/workflows/check.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,18 @@ jobs:
133133
134134
test -f home-smoke/.config/sandcode/sandcode.toml
135135
test -f home-smoke/.config/sandcode/.env
136+
137+
- name: Run installed setup TUI smoke
138+
run: |
139+
cd e2e-install
140+
mkdir -p logs
141+
status=0
142+
timeout 5s script -qefc './node_modules/.bin/sandcode setup' logs/setup-tui.log || status=$?
143+
if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
144+
cat logs/setup-tui.log
145+
exit "$status"
146+
fi
147+
148+
grep -q 'sandcode' logs/setup-tui.log
149+
! grep -q 'Orphan text error' logs/setup-tui.log
150+
! grep -q 'dispose is not a function' logs/setup-tui.log

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ sandcode setup --yes --vault-path ~/vaults/research --obsidian-integration headl
8686

8787
`sandcode setup` uses an OpenTUI wizard by default when a TTY is available.
8888

89+
Keyboard controls:
90+
91+
- `Esc` goes back one step
92+
- `Ctrl+C` exits setup immediately
93+
8994
It writes:
9095

9196
- `~/.config/sandcode/sandcode.toml`

src/setup-ui.tsx

Lines changed: 119 additions & 117 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, Show } from "solid-js";
3+
import { createMemo, createSignal, For } from "solid-js";
44
import {
55
applySetupState,
66
type SetupContext,
@@ -343,7 +343,7 @@ function stateSnapshot(state: SetupState): string[] {
343343
return lines;
344344
}
345345

346-
function SetupWizard(props: {
346+
export function SetupWizard(props: {
347347
context: SetupContext;
348348
initialState: SetupState;
349349
complete: (result: SetupResult) => void;
@@ -539,102 +539,8 @@ function SetupWizard(props: {
539539
backgroundColor="#141a20"
540540
gap={1}
541541
>
542-
<Show when={phase() === "wizard"}>
543-
<Show
544-
when={activeStep().kind === "summary"}
545-
fallback={
546-
<box flexDirection="column" gap={1}>
547-
<Show when={activeChoiceStep()}>
548-
{(stepAccessor) => (
549-
<box flexDirection="column" gap={1}>
550-
<text fg="#8fbcd4">{stepAccessor().eyebrow}</text>
551-
<text>
552-
<strong fg="#f6d365">{stepAccessor().title}</strong>
553-
</text>
554-
<text fg="#d7e3ea">{stepAccessor().description}</text>
555-
<text fg="#7d91a2">{stepAccessor().hint}</text>
556-
<tab_select
557-
focused
558-
options={stepAccessor().options}
559-
selectedIndex={activeChoiceIndex()}
560-
showDescription
561-
onChange={(_index: number, option: WizardChoice | null) => {
562-
if (!option) {
563-
return;
564-
}
565-
setState((current) => {
566-
const next = { ...current };
567-
stepAccessor().commit(next, option.value);
568-
return next;
569-
});
570-
}}
571-
onSelect={(_index: number, option: WizardChoice | null) => {
572-
if (!option) {
573-
return;
574-
}
575-
commitAndAdvance(() => {
576-
setState((current) => {
577-
const next = { ...current };
578-
stepAccessor().commit(next, option.value);
579-
return next;
580-
});
581-
});
582-
}}
583-
/>
584-
</box>
585-
)}
586-
</Show>
587-
588-
<Show when={activeInputStep()}>
589-
{(stepAccessor) => (
590-
<box flexDirection="column" gap={1}>
591-
<text fg="#8fbcd4">{stepAccessor().eyebrow}</text>
592-
<text>
593-
<strong fg="#f6d365">{stepAccessor().title}</strong>
594-
</text>
595-
<text fg="#d7e3ea">{stepAccessor().description}</text>
596-
<text fg="#7d91a2">{stepAccessor().hint}</text>
597-
<Show
598-
when={stepAccessor().key === "sync-timeout" && state().syncTimeoutError}
599-
>
600-
<text fg="#f8b195">{state().syncTimeoutError}</text>
601-
</Show>
602-
<input
603-
focused
604-
value={stepAccessor().value}
605-
placeholder={stepAccessor().placeholder}
606-
onInput={(value: string) => {
607-
setState((current) => {
608-
const next = { ...current };
609-
stepAccessor().commit(next, value);
610-
return next;
611-
});
612-
}}
613-
onSubmit={(value: string) => {
614-
const parsed = Number.parseInt(value.trim(), 10);
615-
const shouldAdvance =
616-
stepAccessor().key !== "sync-timeout" ||
617-
(Number.isInteger(parsed) && parsed > 0);
618-
619-
setState((current) => {
620-
const next = { ...current };
621-
stepAccessor().commit(next, value);
622-
return next;
623-
});
624-
625-
if (shouldAdvance) {
626-
setStepIndex((current) =>
627-
getNextWizardStepIndex(current, steps().length),
628-
);
629-
}
630-
}}
631-
/>
632-
</box>
633-
)}
634-
</Show>
635-
</box>
636-
}
637-
>
542+
{phase() === "wizard" ? (
543+
activeStep().kind === "summary" ? (
638544
<box flexDirection="column" gap={1}>
639545
<text fg="#8fbcd4">Ready</text>
640546
<text>
@@ -673,46 +579,142 @@ function SetupWizard(props: {
673579
}}
674580
/>
675581
</box>
676-
</Show>
677-
</Show>
678-
679-
<Show when={phase() === "saving"}>
582+
) : (
583+
(() => {
584+
const choiceStep = activeChoiceStep();
585+
if (choiceStep) {
586+
return (
587+
<box flexDirection="column" gap={1}>
588+
<text fg="#8fbcd4">{choiceStep.eyebrow}</text>
589+
<text>
590+
<strong fg="#f6d365">{choiceStep.title}</strong>
591+
</text>
592+
<text fg="#d7e3ea">{choiceStep.description}</text>
593+
<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+
/>
622+
</box>
623+
);
624+
}
625+
626+
const inputStep = activeInputStep();
627+
if (inputStep) {
628+
return (
629+
<box flexDirection="column" gap={1}>
630+
<text fg="#8fbcd4">{inputStep.eyebrow}</text>
631+
<text>
632+
<strong fg="#f6d365">{inputStep.title}</strong>
633+
</text>
634+
<text fg="#d7e3ea">{inputStep.description}</text>
635+
<text fg="#7d91a2">{inputStep.hint}</text>
636+
{inputStep.key === "sync-timeout" && state().syncTimeoutError ? (
637+
<text fg="#f8b195">{state().syncTimeoutError}</text>
638+
) : null}
639+
<input
640+
focused
641+
value={inputStep.value}
642+
placeholder={inputStep.placeholder}
643+
onInput={(value: string) => {
644+
setState((current) => {
645+
const next = { ...current };
646+
inputStep.commit(next, value);
647+
return next;
648+
});
649+
}}
650+
onSubmit={(value: string) => {
651+
const parsed = Number.parseInt(value.trim(), 10);
652+
const shouldAdvance =
653+
inputStep.key !== "sync-timeout" ||
654+
(Number.isInteger(parsed) && parsed > 0);
655+
656+
setState((current) => {
657+
const next = { ...current };
658+
inputStep.commit(next, value);
659+
return next;
660+
});
661+
662+
if (shouldAdvance) {
663+
setStepIndex((current) =>
664+
getNextWizardStepIndex(current, steps().length),
665+
);
666+
}
667+
}}
668+
/>
669+
</box>
670+
);
671+
}
672+
673+
return null;
674+
})()
675+
)
676+
) : phase() === "saving" ? (
680677
<box flexDirection="column" gap={1}>
681678
<text fg="#8fbcd4">Writing</text>
682679
<text>
683680
<strong fg="#f6d365">Applying configuration</strong>
684681
</text>
685682
<text fg="#d7e3ea">Running validations and writing files. Stay on this screen.</text>
686683
</box>
687-
</Show>
688-
689-
<Show when={phase() === "done"}>
684+
) : phase() === "done" ? (
690685
<box flexDirection="column" gap={1}>
691686
<text fg="#8fbcd4">Complete</text>
692687
<text>
693688
<strong fg="#9fd3c7">Sandcode is configured.</strong>
694689
</text>
695690
<text fg="#d7e3ea">Press Enter or Esc to leave setup.</text>
696-
<Show when={result()}>
697-
{(saved) => (
691+
{(() => {
692+
const savedResult = result();
693+
if (!savedResult) {
694+
return null;
695+
}
696+
697+
return (
698698
<box border borderColor="#1d313a" padding={1} flexDirection="column" gap={1}>
699-
<text fg="#d7e3ea">Config: {saved().configPath}</text>
700-
<Show when={saved().envPath}>
701-
{(envPath) => <text fg="#d7e3ea">Env: {envPath()}</text>}
702-
</Show>
699+
<text fg="#d7e3ea">Config: {savedResult.configPath}</text>
700+
{savedResult.envPath ? (
701+
<text fg="#d7e3ea">Env: {savedResult.envPath}</text>
702+
) : null}
703703
</box>
704-
)}
705-
</Show>
704+
);
705+
})()}
706706
</box>
707-
</Show>
708-
709-
<Show when={phase() === "error"}>
707+
) : phase() === "error" ? (
710708
<box flexDirection="column" gap={1}>
711709
<text fg="#f8b195">Validation failed</text>
712710
<text fg="#fbe4d8">{errorMessage()}</text>
713711
<text fg="#7d91a2">Press Enter or Esc to go back and edit the setup values.</text>
714712
</box>
715-
</Show>
713+
) : null}
714+
715+
<box marginTop="auto" border borderColor="#1d313a" padding={1}>
716+
<text fg="#7d91a2">Esc goes back. Ctrl+C exits setup immediately.</text>
717+
</box>
716718
</box>
717719
</box>
718720
</box>

0 commit comments

Comments
 (0)