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
15 changes: 15 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
236 changes: 119 additions & 117 deletions src/setup-ui.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -539,102 +539,8 @@ function SetupWizard(props: {
backgroundColor="#141a20"
gap={1}
>
<Show when={phase() === "wizard"}>
<Show
when={activeStep().kind === "summary"}
fallback={
<box flexDirection="column" gap={1}>
<Show when={activeChoiceStep()}>
{(stepAccessor) => (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">{stepAccessor().eyebrow}</text>
<text>
<strong fg="#f6d365">{stepAccessor().title}</strong>
</text>
<text fg="#d7e3ea">{stepAccessor().description}</text>
<text fg="#7d91a2">{stepAccessor().hint}</text>
<tab_select
focused
options={stepAccessor().options}
selectedIndex={activeChoiceIndex()}
showDescription
onChange={(_index: number, option: WizardChoice | null) => {
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;
});
});
}}
/>
</box>
)}
</Show>

<Show when={activeInputStep()}>
{(stepAccessor) => (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">{stepAccessor().eyebrow}</text>
<text>
<strong fg="#f6d365">{stepAccessor().title}</strong>
</text>
<text fg="#d7e3ea">{stepAccessor().description}</text>
<text fg="#7d91a2">{stepAccessor().hint}</text>
<Show
when={stepAccessor().key === "sync-timeout" && state().syncTimeoutError}
>
<text fg="#f8b195">{state().syncTimeoutError}</text>
</Show>
<input
focused
value={stepAccessor().value}
placeholder={stepAccessor().placeholder}
onInput={(value: string) => {
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),
);
}
}}
/>
</box>
)}
</Show>
</box>
}
>
{phase() === "wizard" ? (
activeStep().kind === "summary" ? (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">Ready</text>
<text>
Expand Down Expand Up @@ -673,46 +579,142 @@ function SetupWizard(props: {
}}
/>
</box>
</Show>
</Show>

<Show when={phase() === "saving"}>
) : (
(() => {
const choiceStep = activeChoiceStep();
if (choiceStep) {
return (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">{choiceStep.eyebrow}</text>
<text>
<strong fg="#f6d365">{choiceStep.title}</strong>
</text>
<text fg="#d7e3ea">{choiceStep.description}</text>
<text fg="#7d91a2">{choiceStep.hint}</text>
<tab_select
focused
options={choiceStep.options}
selectedIndex={activeChoiceIndex()}
showDescription
onChange={(_index: number, option: WizardChoice | null) => {
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;
});
});
}}
/>
</box>
);
}

const inputStep = activeInputStep();
if (inputStep) {
return (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">{inputStep.eyebrow}</text>
<text>
<strong fg="#f6d365">{inputStep.title}</strong>
</text>
<text fg="#d7e3ea">{inputStep.description}</text>
<text fg="#7d91a2">{inputStep.hint}</text>
{inputStep.key === "sync-timeout" && state().syncTimeoutError ? (
<text fg="#f8b195">{state().syncTimeoutError}</text>
) : null}
<input
focused
value={inputStep.value}
placeholder={inputStep.placeholder}
onInput={(value: string) => {
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),
);
}
}}
/>
</box>
);
}

return null;
})()
)
) : phase() === "saving" ? (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">Writing</text>
<text>
<strong fg="#f6d365">Applying configuration</strong>
</text>
<text fg="#d7e3ea">Running validations and writing files. Stay on this screen.</text>
</box>
</Show>

<Show when={phase() === "done"}>
) : phase() === "done" ? (
<box flexDirection="column" gap={1}>
<text fg="#8fbcd4">Complete</text>
<text>
<strong fg="#9fd3c7">Sandcode is configured.</strong>
</text>
<text fg="#d7e3ea">Press Enter or Esc to leave setup.</text>
<Show when={result()}>
{(saved) => (
{(() => {
const savedResult = result();
if (!savedResult) {
return null;
}

return (
<box border borderColor="#1d313a" padding={1} flexDirection="column" gap={1}>
<text fg="#d7e3ea">Config: {saved().configPath}</text>
<Show when={saved().envPath}>
{(envPath) => <text fg="#d7e3ea">Env: {envPath()}</text>}
</Show>
<text fg="#d7e3ea">Config: {savedResult.configPath}</text>
{savedResult.envPath ? (
<text fg="#d7e3ea">Env: {savedResult.envPath}</text>
) : null}
</box>
)}
</Show>
);
})()}
</box>
</Show>

<Show when={phase() === "error"}>
) : phase() === "error" ? (
<box flexDirection="column" gap={1}>
<text fg="#f8b195">Validation failed</text>
<text fg="#fbe4d8">{errorMessage()}</text>
<text fg="#7d91a2">Press Enter or Esc to go back and edit the setup values.</text>
</box>
</Show>
) : null}

<box marginTop="auto" border borderColor="#1d313a" padding={1}>
<text fg="#7d91a2">Esc goes back. Ctrl+C exits setup immediately.</text>
</box>
</box>
</box>
</box>
Expand Down