diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index d696277..7c48adf 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -133,3 +133,18 @@ jobs:
test -f home-smoke/.config/sandcode/sandcode.toml
test -f home-smoke/.config/sandcode/.env
+
+ - name: Run installed setup TUI smoke
+ run: |
+ cd e2e-install
+ mkdir -p logs
+ status=0
+ timeout 5s script -qefc './node_modules/.bin/sandcode setup' logs/setup-tui.log || status=$?
+ if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
+ cat logs/setup-tui.log
+ exit "$status"
+ fi
+
+ grep -q 'sandcode' logs/setup-tui.log
+ ! grep -q 'Orphan text error' logs/setup-tui.log
+ ! grep -q 'dispose is not a function' logs/setup-tui.log
diff --git a/README.md b/README.md
index ad5244a..de84706 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,11 @@ sandcode setup --yes --vault-path ~/vaults/research --obsidian-integration headl
`sandcode setup` uses an OpenTUI wizard by default when a TTY is available.
+Keyboard controls:
+
+- `Esc` goes back one step
+- `Ctrl+C` exits setup immediately
+
It writes:
- `~/.config/sandcode/sandcode.toml`
diff --git a/src/setup-ui.tsx b/src/setup-ui.tsx
index 6c086fb..2c03808 100644
--- a/src/setup-ui.tsx
+++ b/src/setup-ui.tsx
@@ -1,6 +1,6 @@
import { createCliRenderer } from "@opentui/core";
import { render, useKeyboard } from "@opentui/solid";
-import { createMemo, createSignal, For, Show } from "solid-js";
+import { createMemo, createSignal, For } from "solid-js";
import {
applySetupState,
type SetupContext,
@@ -343,7 +343,7 @@ function stateSnapshot(state: SetupState): string[] {
return lines;
}
-function SetupWizard(props: {
+export function SetupWizard(props: {
context: SetupContext;
initialState: SetupState;
complete: (result: SetupResult) => void;
@@ -539,102 +539,8 @@ function SetupWizard(props: {
backgroundColor="#141a20"
gap={1}
>
-
-
-
- {(stepAccessor) => (
-
- {stepAccessor().eyebrow}
-
- {stepAccessor().title}
-
- {stepAccessor().description}
- {stepAccessor().hint}
- {
- if (!option) {
- return;
- }
- setState((current) => {
- const next = { ...current };
- stepAccessor().commit(next, option.value);
- return next;
- });
- }}
- onSelect={(_index: number, option: WizardChoice | null) => {
- if (!option) {
- return;
- }
- commitAndAdvance(() => {
- setState((current) => {
- const next = { ...current };
- stepAccessor().commit(next, option.value);
- return next;
- });
- });
- }}
- />
-
- )}
-
-
-
- {(stepAccessor) => (
-
- {stepAccessor().eyebrow}
-
- {stepAccessor().title}
-
- {stepAccessor().description}
- {stepAccessor().hint}
-
- {state().syncTimeoutError}
-
- {
- setState((current) => {
- const next = { ...current };
- stepAccessor().commit(next, value);
- return next;
- });
- }}
- onSubmit={(value: string) => {
- const parsed = Number.parseInt(value.trim(), 10);
- const shouldAdvance =
- stepAccessor().key !== "sync-timeout" ||
- (Number.isInteger(parsed) && parsed > 0);
-
- setState((current) => {
- const next = { ...current };
- stepAccessor().commit(next, value);
- return next;
- });
-
- if (shouldAdvance) {
- setStepIndex((current) =>
- getNextWizardStepIndex(current, steps().length),
- );
- }
- }}
- />
-
- )}
-
-
- }
- >
+ {phase() === "wizard" ? (
+ activeStep().kind === "summary" ? (
Ready
@@ -673,10 +579,101 @@ function SetupWizard(props: {
}}
/>
-
-
-
-
+ ) : (
+ (() => {
+ const choiceStep = activeChoiceStep();
+ if (choiceStep) {
+ return (
+
+ {choiceStep.eyebrow}
+
+ {choiceStep.title}
+
+ {choiceStep.description}
+ {choiceStep.hint}
+ {
+ if (!option) {
+ return;
+ }
+ setState((current) => {
+ const next = { ...current };
+ choiceStep.commit(next, option.value);
+ return next;
+ });
+ }}
+ onSelect={(_index: number, option: WizardChoice | null) => {
+ if (!option) {
+ return;
+ }
+ commitAndAdvance(() => {
+ setState((current) => {
+ const next = { ...current };
+ choiceStep.commit(next, option.value);
+ return next;
+ });
+ });
+ }}
+ />
+
+ );
+ }
+
+ const inputStep = activeInputStep();
+ if (inputStep) {
+ return (
+
+ {inputStep.eyebrow}
+
+ {inputStep.title}
+
+ {inputStep.description}
+ {inputStep.hint}
+ {inputStep.key === "sync-timeout" && state().syncTimeoutError ? (
+ {state().syncTimeoutError}
+ ) : null}
+ {
+ setState((current) => {
+ const next = { ...current };
+ inputStep.commit(next, value);
+ return next;
+ });
+ }}
+ onSubmit={(value: string) => {
+ const parsed = Number.parseInt(value.trim(), 10);
+ const shouldAdvance =
+ inputStep.key !== "sync-timeout" ||
+ (Number.isInteger(parsed) && parsed > 0);
+
+ setState((current) => {
+ const next = { ...current };
+ inputStep.commit(next, value);
+ return next;
+ });
+
+ if (shouldAdvance) {
+ setStepIndex((current) =>
+ getNextWizardStepIndex(current, steps().length),
+ );
+ }
+ }}
+ />
+
+ );
+ }
+
+ return null;
+ })()
+ )
+ ) : phase() === "saving" ? (
Writing
@@ -684,35 +681,40 @@ function SetupWizard(props: {
Running validations and writing files. Stay on this screen.
-
-
-
+ ) : phase() === "done" ? (
Complete
Sandcode is configured.
Press Enter or Esc to leave setup.
-
- {(saved) => (
+ {(() => {
+ const savedResult = result();
+ if (!savedResult) {
+ return null;
+ }
+
+ return (
- Config: {saved().configPath}
-
- {(envPath) => Env: {envPath()}}
-
+ Config: {savedResult.configPath}
+ {savedResult.envPath ? (
+ Env: {savedResult.envPath}
+ ) : null}
- )}
-
+ );
+ })()}
-
-
-
+ ) : phase() === "error" ? (
Validation failed
{errorMessage()}
Press Enter or Esc to go back and edit the setup values.
-
+ ) : null}
+
+
+ Esc goes back. Ctrl+C exits setup immediately.
+