Skip to content

Commit 3a78d6f

Browse files
committed
wip: add OpenTUI theme and CLI demo
1 parent d1e2849 commit 3a78d6f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+3638
-5
lines changed

cli/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# @aidbox-forms/cli
2+
3+
Terminal demo app for `@aidbox-forms/renderer` using `@aidbox-forms/opentui-theme`.
4+
5+
## Run
6+
7+
```bash
8+
pnpm -C cli dev -- --questionnaire site/stories/questionnaire/samples/text-controls.json --output response.json
9+
```
10+
11+
On submit, the app exits and writes a `QuestionnaireResponse` JSON to `--output`.
12+
If `--output` is omitted, it prints the response JSON to stdout and exits.
13+
14+
### Flags
15+
16+
- `--questionnaire <path>` (required)
17+
- `--initial-response <path>`
18+
- `--terminology-server-url <url>`
19+
- `--output <path>`
20+
21+
## Notes
22+
23+
- Attachment upload is not supported in TUI (TBD).

cli/package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@aidbox-forms/cli",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "bun src/index.tsx",
8+
"lint": "eslint .",
9+
"test": "echo \"@aidbox-forms/cli: no tests defined\""
10+
},
11+
"dependencies": {
12+
"@aidbox-forms/opentui-theme": "workspace:*",
13+
"@aidbox-forms/renderer": "workspace:*",
14+
"@lhncbc/ucum-lhc": "7.1.3",
15+
"@opentui/core": "^0.1.72",
16+
"@opentui/react": "^0.1.72",
17+
"classnames": "^2.5.1",
18+
"fhirpath": "^4.6.0",
19+
"mobx": "^6.15.0",
20+
"mobx-react-lite": "^4.1.1",
21+
"mobx-utils": "^6.1.1",
22+
"react": "^19.2.0"
23+
},
24+
"devDependencies": {
25+
"@types/bun": "^1.2.22",
26+
"@types/fhir": "^0.0.41",
27+
"@types/node": "^24.10.3",
28+
"@types/react": "^19.2.2",
29+
"typescript": "~5.9.3"
30+
}
31+
}

cli/src/css.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module "*.css";

cli/src/index.tsx

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import Renderer from "@aidbox-forms/renderer";
2+
import {
3+
Provider as FocusProvider,
4+
theme as opentuiTheme,
5+
} from "@aidbox-forms/opentui-theme";
6+
import { createCliRenderer } from "@opentui/core";
7+
import { createRoot, useKeyboard, useRenderer } from "@opentui/react";
8+
import { readFile, writeFile } from "node:fs/promises";
9+
import path from "node:path";
10+
import process from "node:process";
11+
import { fileURLToPath } from "node:url";
12+
import { parseArgs } from "node:util";
13+
import type { Questionnaire, QuestionnaireResponse } from "fhir/r5";
14+
import { useCallback, useEffect, useMemo } from "react";
15+
16+
type CliOptions = {
17+
questionnairePath: string;
18+
initialResponsePath: string | undefined;
19+
terminologyServerUrl: string | undefined;
20+
outputPath: string | undefined;
21+
};
22+
23+
type CompletionState =
24+
| { status: "submit"; response: QuestionnaireResponse }
25+
| { status: "exit" };
26+
27+
type CompletionHandler = (state: CompletionState) => void;
28+
29+
type Deferred<T> = {
30+
promise: Promise<T>;
31+
resolve: (value: T) => void;
32+
reject: (reason?: unknown) => void;
33+
};
34+
35+
function createDeferred<T>(): Deferred<T> {
36+
let resolve: ((value: T) => void) | undefined;
37+
let reject: ((reason?: unknown) => void) | undefined;
38+
39+
const promise = new Promise<T>((resolveFunction, rejectFunction) => {
40+
resolve = resolveFunction;
41+
reject = rejectFunction;
42+
});
43+
44+
if (!resolve || !reject) {
45+
throw new Error("Failed to create deferred promise.");
46+
}
47+
48+
return { promise, resolve, reject };
49+
}
50+
51+
function usage(): string {
52+
return `Usage:
53+
pnpm -C cli dev -- --questionnaire <path> [--initial-response <path>] [--terminology-server-url <url>] [--output <path>]
54+
55+
Flags:
56+
--questionnaire Path to Questionnaire JSON (required)
57+
--initial-response Path to QuestionnaireResponse JSON
58+
--terminology-server-url FHIR terminology server base URL
59+
--output Write response JSON to this file on submit
60+
`;
61+
}
62+
63+
function findAttachmentItems(items: unknown): boolean {
64+
if (!Array.isArray(items)) return false;
65+
66+
for (const entry of items) {
67+
if (!entry || typeof entry !== "object") continue;
68+
69+
const item = entry as { type?: unknown; item?: unknown };
70+
71+
if (item.type === "attachment") {
72+
return true;
73+
}
74+
75+
if (findAttachmentItems(item.item)) {
76+
return true;
77+
}
78+
}
79+
80+
return false;
81+
}
82+
83+
async function readJsonFile<T>(filePath: string): Promise<T> {
84+
const text = await readFile(filePath, "utf8");
85+
return JSON.parse(text) as T;
86+
}
87+
88+
function stringifyPretty(value: unknown): string {
89+
return JSON.stringify(value, undefined, 2);
90+
}
91+
92+
function App({
93+
questionnaire,
94+
initialResponse,
95+
terminologyServerUrl,
96+
hasAttachments,
97+
onComplete,
98+
}: {
99+
questionnaire: Questionnaire;
100+
initialResponse: QuestionnaireResponse | undefined;
101+
terminologyServerUrl: string | undefined;
102+
hasAttachments: boolean;
103+
onComplete: CompletionHandler;
104+
}) {
105+
const renderer = useRenderer();
106+
107+
useEffect(() => {
108+
if (!hasAttachments) return;
109+
110+
renderer.console.show();
111+
console.warn(
112+
"This Questionnaire contains attachment items. Upload is not supported in the TUI (TBD).",
113+
);
114+
}, [hasAttachments, renderer]);
115+
116+
useKeyboard((key) => {
117+
if (key.eventType !== "press") return;
118+
119+
if (key.name === "escape") {
120+
key.preventDefault();
121+
key.stopPropagation();
122+
onComplete({ status: "exit" });
123+
}
124+
});
125+
126+
const onSubmit = useCallback(
127+
(response: QuestionnaireResponse) => {
128+
onComplete({ status: "submit", response });
129+
},
130+
[onComplete],
131+
);
132+
133+
const header = useMemo(() => {
134+
return (
135+
<box flexDirection="column" style={{ gap: 0, marginBottom: 1 }}>
136+
<text>
137+
Aidbox Forms TUI <span fg="#666666">(OpenTUI)</span>
138+
</text>
139+
<text fg="#666666">Tab navigate • Ctrl+S submit • Esc quit</text>
140+
</box>
141+
);
142+
}, []);
143+
144+
return (
145+
<FocusProvider>
146+
<box flexDirection="column">
147+
{header}
148+
<Renderer
149+
questionnaire={questionnaire}
150+
initialResponse={initialResponse}
151+
terminologyServerUrl={terminologyServerUrl}
152+
theme={opentuiTheme}
153+
onSubmit={onSubmit}
154+
/>
155+
</box>
156+
</FocusProvider>
157+
);
158+
}
159+
160+
const moduleFilePath = fileURLToPath(import.meta.url);
161+
const moduleDirectoryPath = path.dirname(moduleFilePath);
162+
const workspaceRootPath = path.resolve(moduleDirectoryPath, "..", "..");
163+
if (process.cwd() !== workspaceRootPath) {
164+
process.chdir(workspaceRootPath);
165+
}
166+
167+
const { values } = parseArgs({
168+
options: {
169+
questionnaire: { type: "string" },
170+
"initial-response": { type: "string" },
171+
"terminology-server-url": { type: "string" },
172+
output: { type: "string" },
173+
help: { type: "boolean" },
174+
},
175+
allowPositionals: false,
176+
});
177+
178+
if (values.help) {
179+
console.log(usage());
180+
} else if (values.questionnaire) {
181+
const options: CliOptions = {
182+
questionnairePath: path.resolve(process.cwd(), values.questionnaire),
183+
initialResponsePath: values["initial-response"]
184+
? path.resolve(process.cwd(), values["initial-response"])
185+
: undefined,
186+
terminologyServerUrl: values["terminology-server-url"],
187+
outputPath: values.output
188+
? path.resolve(process.cwd(), values.output)
189+
: undefined,
190+
};
191+
192+
const questionnaire = await readJsonFile<Questionnaire>(
193+
options.questionnairePath,
194+
);
195+
const initialResponse = options.initialResponsePath
196+
? await readJsonFile<QuestionnaireResponse>(options.initialResponsePath)
197+
: undefined;
198+
199+
const hasAttachments = findAttachmentItems(questionnaire.item);
200+
201+
if (hasAttachments) {
202+
console.warn(
203+
"Warning: attachment items detected; upload is not supported in the TUI (TBD).",
204+
);
205+
}
206+
207+
const renderer = await createCliRenderer();
208+
209+
const completion = createDeferred<CompletionState>();
210+
let isCompleted = false;
211+
212+
const completeOnce: CompletionHandler = (state) => {
213+
if (isCompleted) return;
214+
isCompleted = true;
215+
completion.resolve(state);
216+
};
217+
218+
renderer.once("destroy", () => {
219+
completeOnce({ status: "exit" });
220+
});
221+
222+
const root = createRoot(renderer);
223+
root.render(
224+
<App
225+
questionnaire={questionnaire}
226+
initialResponse={initialResponse}
227+
terminologyServerUrl={options.terminologyServerUrl}
228+
hasAttachments={hasAttachments}
229+
onComplete={completeOnce}
230+
/>,
231+
);
232+
233+
const completionState = await completion.promise;
234+
235+
renderer.destroy();
236+
237+
if (completionState.status === "submit") {
238+
const serialized = stringifyPretty(completionState.response);
239+
240+
try {
241+
if (options.outputPath) {
242+
await writeFile(options.outputPath, serialized, "utf8");
243+
} else {
244+
process.stdout.write(`${serialized}\n`);
245+
}
246+
247+
process.exitCode = 0;
248+
} catch (error) {
249+
console.error(error);
250+
process.exitCode = 1;
251+
}
252+
} else {
253+
process.exitCode = 0;
254+
}
255+
} else {
256+
console.error("Missing required flag: --questionnaire");
257+
console.log(usage());
258+
process.exitCode = 1;
259+
}

cli/tsconfig.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"extends": "../tsconfig.base.json",
3+
"compilerOptions": {
4+
"jsxImportSource": "@opentui/react",
5+
"baseUrl": "..",
6+
"paths": {
7+
"@aidbox-forms/theme": ["packages/theme/lib"],
8+
"@aidbox-forms/antd-theme": ["themes/antd-theme/lib"],
9+
"@aidbox-forms/mantine-theme": ["themes/mantine-theme/lib"],
10+
"@aidbox-forms/hs-theme": ["themes/hs-theme/lib"],
11+
"@aidbox-forms/nshuk-theme": ["themes/nshuk-theme/lib"],
12+
"@aidbox-forms/opentui-theme": ["themes/opentui-theme/lib"],
13+
"@aidbox-forms/renderer": ["packages/renderer/lib"],
14+
"@aidbox-forms/renderer/*": ["packages/renderer/lib/*"]
15+
}
16+
},
17+
"include": ["src/**/*"]
18+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
},
1717
"devDependencies": {
1818
"@eslint/js": "^9.39.2",
19+
"@opentui/react": "^0.1.72",
1920
"@types/node": "^25.0.7",
21+
"@types/react": "^19.2.2",
2022
"eslint": "^9.39.2",
23+
"react": "^19.2.0",
2124
"eslint-plugin-react-hooks": "^7.0.1",
2225
"eslint-plugin-react-refresh": "^0.4.26",
2326
"eslint-plugin-storybook": "^10.1.11",

packages/renderer/lib/ui/theme.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
/* eslint-disable react-refresh/only-export-components */
2-
import { theme } from "@aidbox-forms/hs-theme";
32
import type { Theme } from "@aidbox-forms/theme";
43
import { createContext, type PropsWithChildren, useContext } from "react";
54

6-
const ThemeContext = createContext<Theme>(theme);
5+
const fallbackTheme = {} as Theme;
6+
7+
let defaultTheme: Theme = fallbackTheme;
8+
9+
try {
10+
const module = await import("@aidbox-forms/hs-theme");
11+
defaultTheme = module.theme;
12+
} catch {
13+
defaultTheme = fallbackTheme;
14+
}
15+
16+
const ThemeContext = createContext<Theme>(defaultTheme);
717

818
export function ThemeProvider({
9-
theme: providedTheme = theme,
19+
theme,
1020
children,
1121
}: PropsWithChildren<{ theme?: Theme | undefined }>) {
1222
return (
13-
<ThemeContext.Provider value={providedTheme}>
23+
<ThemeContext.Provider value={theme ?? defaultTheme}>
1424
{children}
1525
</ThemeContext.Provider>
1626
);

packages/renderer/tsconfig.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
{
2+
"compilerOptions": {
3+
"baseUrl": "../..",
4+
"paths": {
5+
"@aidbox-forms/theme": ["packages/theme/lib"],
6+
"@aidbox-forms/antd-theme": ["themes/antd-theme/lib"],
7+
"@aidbox-forms/mantine-theme": ["themes/mantine-theme/lib"],
8+
"@aidbox-forms/hs-theme": ["themes/hs-theme/lib"],
9+
"@aidbox-forms/nshuk-theme": ["themes/nshuk-theme/lib"],
10+
"@aidbox-forms/opentui-theme": ["themes/opentui-theme/lib"],
11+
"@aidbox-forms/renderer": ["packages/renderer/lib"],
12+
"@aidbox-forms/renderer/*": ["packages/renderer/lib/*"]
13+
}
14+
},
215
"files": [],
316
"references": [
417
{ "path": "./tsconfig.lib.json" },

0 commit comments

Comments
 (0)