Skip to content

Commit 80bb7b2

Browse files
committed
feat(onboarding): implement event-driven onboarding flow with TUI components
Add new onboarding module with service, emitter, and TUI components for interactive workflow setup. Includes: - OnboardingService for state management and event emission - Typing effect animations and keyboard navigation - Reusable UI components for project name input, option lists, and hints - Debug logging throughout the flow - Integration with existing workflow event system - Backward compatibility with legacy onboarding flow
1 parent 71d022d commit 80bb7b2

File tree

17 files changed

+1686
-606
lines changed

17 files changed

+1686
-606
lines changed

src/cli/tui/app-shell.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import { Home } from "@tui/routes/home"
1818
import { Workflow } from "@tui/routes/workflow"
1919
import { Onboard } from "@tui/routes/onboard"
2020
import { homedir } from "os"
21-
import { WorkflowEventBus } from "../../workflows/events/index.js"
21+
import { WorkflowEventBus, OnboardingService } from "../../workflows/events/index.js"
22+
import { debug, setDebugLogFile } from "../../shared/logging/logger.js"
2223
import { MonitoringCleanup } from "../../agents/monitoring/index.js"
2324
import path from "path"
2425
import { createRequire } from "node:module"
@@ -129,6 +130,8 @@ export function App(props: { initialToast?: InitialToast }) {
129130
const [templateConditionGroups, setTemplateConditionGroups] = createSignal<ConditionGroup[] | null>(null)
130131
const [initialProjectName, setInitialProjectName] = createSignal<string | null>(null)
131132
const [controllerAgents, setControllerAgents] = createSignal<AgentDefinition[] | null>(null)
133+
const [onboardingService, setOnboardingService] = createSignal<OnboardingService | null>(null)
134+
const [onboardingEventBus, setOnboardingEventBus] = createSignal<WorkflowEventBus | null>(null)
132135

133136
let pendingWorkflowStart: (() => void) | null = null
134137

@@ -143,6 +146,15 @@ export function App(props: { initialToast?: InitialToast }) {
143146
const cwd = process.env.CODEMACHINE_CWD || process.cwd()
144147
const cmRoot = path.join(cwd, '.codemachine')
145148

149+
// Initialize debug log file early (before onboarding) so all logs are captured
150+
const rawLogLevel = (process.env.LOG_LEVEL || '').trim().toLowerCase()
151+
const debugFlag = (process.env.DEBUG || '').trim().toLowerCase()
152+
const debugEnabled = rawLogLevel === 'debug' || (debugFlag !== '' && debugFlag !== '0' && debugFlag !== 'false')
153+
if (debugEnabled) {
154+
const debugLogPath = path.join(cwd, '.codemachine', 'logs', 'workflow-debug.log')
155+
setDebugLogFile(debugLogPath)
156+
}
157+
146158
// Check if tracks/conditions exist and no selection yet
147159
try {
148160
const templatePath = await getTemplatePathFromTracking(cmRoot)
@@ -169,10 +181,38 @@ export function App(props: { initialToast?: InitialToast }) {
169181

170182
// If project name, tracks, conditions, or controller need selection, show onboard view
171183
if (needsProjectName || needsTrackSelection || needsConditionsSelection || needsControllerSelection) {
184+
debug('[AppShell] Starting onboarding flow')
185+
186+
// Store config for Onboard component (backward compatibility)
172187
if (hasTracks) setTemplateTracks(template.tracks!)
173188
if (hasConditionGroups) setTemplateConditionGroups(template.conditionGroups!)
174189
if (needsControllerSelection) setControllerAgents(controllers)
175-
setInitialProjectName(existingProjectName) // Pass existing name if any (to skip that step)
190+
setInitialProjectName(existingProjectName)
191+
192+
// Create event bus and service for onboarding
193+
const eventBus = new WorkflowEventBus()
194+
setOnboardingEventBus(eventBus)
195+
196+
const service = new OnboardingService(eventBus, {
197+
tracks: hasTracks ? template.tracks : undefined,
198+
conditionGroups: hasConditionGroups ? template.conditionGroups : undefined,
199+
controllerAgents: needsControllerSelection ? controllers : undefined,
200+
initialProjectName: existingProjectName,
201+
})
202+
setOnboardingService(service)
203+
204+
// Subscribe to completion event
205+
eventBus.on('onboard:completed', (event) => {
206+
debug('[AppShell] onboard:completed received result=%o', event.result)
207+
handleOnboardComplete(event.result)
208+
})
209+
210+
// Subscribe to cancel event
211+
eventBus.on('onboard:cancelled', () => {
212+
debug('[AppShell] onboard:cancelled received')
213+
handleOnboardCancel()
214+
})
215+
176216
currentView = "onboard"
177217
setView("onboard")
178218
return
@@ -348,6 +388,8 @@ export function App(props: { initialToast?: InitialToast }) {
348388
initialProjectName={initialProjectName()}
349389
onComplete={handleOnboardComplete}
350390
onCancel={handleOnboardCancel}
391+
eventBus={onboardingEventBus() ?? undefined}
392+
service={onboardingService() ?? undefined}
351393
/>
352394
</Match>
353395
<Match when={view() === "workflow"}>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/** @jsxImportSource @opentui/solid */
2+
/**
3+
* Footer Hints Component
4+
*
5+
* Displays keyboard hints based on current step.
6+
*/
7+
8+
import { Show } from "solid-js"
9+
import type { Accessor } from "solid-js"
10+
import { useTheme } from "@tui/shared/context/theme"
11+
import type { OnboardStep } from "../../../../../workflows/events/types"
12+
13+
export interface FooterHintsProps {
14+
/** Current onboarding step */
15+
currentStep: Accessor<OnboardStep>
16+
/** Whether current step is multi-select */
17+
isMultiSelect: Accessor<boolean>
18+
}
19+
20+
export function FooterHints(props: FooterHintsProps) {
21+
const themeCtx = useTheme()
22+
23+
const isConditionStep = () => {
24+
const step = props.currentStep()
25+
return step === 'condition_group' || step === 'condition_child'
26+
}
27+
28+
return (
29+
<box marginTop={2}>
30+
<Show when={props.currentStep() === 'project_name'}>
31+
<text fg={themeCtx.theme.textMuted}>
32+
[Enter] Confirm [Esc] Cancel
33+
</text>
34+
</Show>
35+
36+
<Show when={props.currentStep() === 'tracks'}>
37+
<text fg={themeCtx.theme.textMuted}>
38+
[Up/Down] Navigate [Enter] Select [Esc] Cancel
39+
</text>
40+
</Show>
41+
42+
<Show when={isConditionStep() && props.isMultiSelect()}>
43+
<text fg={themeCtx.theme.textMuted}>
44+
[Up/Down] Navigate [Enter] Toggle [Tab] Confirm [Esc] Cancel
45+
</text>
46+
</Show>
47+
48+
<Show when={isConditionStep() && !props.isMultiSelect()}>
49+
<text fg={themeCtx.theme.textMuted}>
50+
[Up/Down] Navigate [Enter] Select [Esc] Cancel
51+
</text>
52+
</Show>
53+
54+
<Show when={props.currentStep() === 'controller'}>
55+
<text fg={themeCtx.theme.textMuted}>
56+
[Up/Down] Navigate [Enter] Select [Esc] Cancel
57+
</text>
58+
</Show>
59+
</box>
60+
)
61+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/** @jsxImportSource @opentui/solid */
2+
/**
3+
* Option List Component
4+
*
5+
* Generic selectable list with radio or checkbox styling.
6+
*/
7+
8+
import { For } from "solid-js"
9+
import type { Accessor } from "solid-js"
10+
import { useTheme } from "@tui/shared/context/theme"
11+
12+
export interface OptionItem {
13+
id: string
14+
label: string
15+
description?: string
16+
}
17+
18+
export interface OptionListProps {
19+
/** List of options to display */
20+
options: OptionItem[]
21+
/** Currently selected index */
22+
selectedIndex: Accessor<number>
23+
/** Whether this is a multi-select list */
24+
multiSelect?: boolean
25+
/** Check if an option is checked (for multi-select) */
26+
isChecked?: (id: string) => boolean
27+
}
28+
29+
export function OptionList(props: OptionListProps) {
30+
const themeCtx = useTheme()
31+
32+
return (
33+
<For each={props.options}>
34+
{(option, index) => {
35+
const isSelected = () => index() === props.selectedIndex()
36+
const isChecked = () => props.isChecked?.(option.id) ?? false
37+
const multiSelect = props.multiSelect ?? false
38+
39+
return (
40+
<box flexDirection="column">
41+
<box flexDirection="row" gap={1}>
42+
<text fg={isSelected() ? themeCtx.theme.primary : themeCtx.theme.textMuted}>
43+
{isSelected() ? ">" : " "}
44+
</text>
45+
<text
46+
fg={
47+
multiSelect
48+
? isChecked()
49+
? themeCtx.theme.primary
50+
: themeCtx.theme.textMuted
51+
: isSelected()
52+
? themeCtx.theme.primary
53+
: themeCtx.theme.textMuted
54+
}
55+
>
56+
{multiSelect
57+
? isChecked()
58+
? "[x]"
59+
: "[ ]"
60+
: isSelected()
61+
? "(*)"
62+
: "( )"}
63+
</text>
64+
<text fg={isSelected() ? themeCtx.theme.primary : themeCtx.theme.text}>
65+
{option.label}
66+
</text>
67+
</box>
68+
{option.description && (
69+
<box marginLeft={6}>
70+
<text fg={themeCtx.theme.textMuted}>{option.description}</text>
71+
</box>
72+
)}
73+
</box>
74+
)
75+
}}
76+
</For>
77+
)
78+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/** @jsxImportSource @opentui/solid */
2+
/**
3+
* Project Name Input Component
4+
*
5+
* Text input for entering project name.
6+
*/
7+
8+
import type { Accessor } from "solid-js"
9+
import { useTheme } from "@tui/shared/context/theme"
10+
11+
export interface ProjectNameInputProps {
12+
/** Current project name value */
13+
value: Accessor<string>
14+
/** Whether typing animation is complete (shows cursor) */
15+
typingDone: Accessor<boolean>
16+
}
17+
18+
export function ProjectNameInput(props: ProjectNameInputProps) {
19+
const themeCtx = useTheme()
20+
21+
return (
22+
<box flexDirection="row" gap={1}>
23+
<text fg={themeCtx.theme.primary}>{">"}</text>
24+
<box
25+
backgroundColor={themeCtx.theme.backgroundElement}
26+
paddingLeft={1}
27+
paddingRight={1}
28+
minWidth={30}
29+
>
30+
<text fg={themeCtx.theme.text}>
31+
{props.value()}{props.typingDone() ? "_" : ""}
32+
</text>
33+
</box>
34+
</box>
35+
)
36+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/** @jsxImportSource @opentui/solid */
2+
/**
3+
* Question Display Component
4+
*
5+
* Shows PO avatar and typing animation for questions.
6+
*/
7+
8+
import type { Accessor } from "solid-js"
9+
import { useTheme } from "@tui/shared/context/theme"
10+
11+
export interface QuestionDisplayProps {
12+
/** Currently typed portion of text */
13+
typedText: Accessor<string>
14+
/** Whether typing animation is complete */
15+
typingDone: Accessor<boolean>
16+
}
17+
18+
export function QuestionDisplay(props: QuestionDisplayProps) {
19+
const themeCtx = useTheme()
20+
21+
return (
22+
<>
23+
{/* PO Avatar */}
24+
<box flexDirection="row" gap={2} marginBottom={1}>
25+
<box
26+
backgroundColor={themeCtx.theme.backgroundElement}
27+
paddingLeft={1}
28+
paddingRight={1}
29+
>
30+
<text fg={themeCtx.theme.text}>[0.0]</text>
31+
</box>
32+
<text fg={themeCtx.theme.textMuted}>PO</text>
33+
</box>
34+
35+
{/* Typing question */}
36+
<box marginBottom={1}>
37+
<text fg={themeCtx.theme.text}>
38+
"{props.typedText()}{props.typingDone() ? "" : "_"}"
39+
</text>
40+
</box>
41+
</>
42+
)
43+
}

0 commit comments

Comments
 (0)